diff --git a/common/event/event.go b/common/event/event.go new file mode 100644 index 000000000..0aef5916e --- /dev/null +++ b/common/event/event.go @@ -0,0 +1,67 @@ +// Copyright 2026 gorse Project Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package event + +import ( + "context" + "time" +) + +// APIEvent represents an API call event for billing purposes. +type APIEvent struct { + // Request metadata + RequestID string // Unique request identifier (X-Request-ID) + Method string // HTTP method (GET, POST, PUT, DELETE, PATCH) + Path string // API path (e.g., /api/recommend/{user-id}) + + // Response metadata + StatusCode int // HTTP response status code + ResponseTime int64 // Response time in milliseconds + Timestamp time.Time // Event timestamp + + // Additional metadata + RemoteAddr string // Client remote address +} + +// StorageEvent represents data storage usage for billing purposes. +type StorageEvent struct { + UserCount int // Number of users in storage + ItemCount int // Number of items in storage + FeedbackCount int // Number of feedbacks in storage + Timestamp time.Time // Event timestamp +} + +type Recorder interface { + RecordAPI(ctx context.Context, event APIEvent) + RecordStorage(ctx context.Context, event StorageEvent) +} + +type NopRecorder struct{} + +func (n *NopRecorder) RecordAPI(ctx context.Context, event APIEvent) { +} + +func (n *NopRecorder) RecordStorage(ctx context.Context, event StorageEvent) { +} + +var recorder Recorder = &NopRecorder{} + +func EventRecorder() Recorder { + return recorder +} + +func SetEventRecorder(r Recorder) { + recorder = r +} diff --git a/common/event/event_test.go b/common/event/event_test.go new file mode 100644 index 000000000..678f81e13 --- /dev/null +++ b/common/event/event_test.go @@ -0,0 +1,72 @@ +// Copyright 2026 gorse Project Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package event + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockRecorder struct { + mock.Mock +} + +func (m *MockRecorder) RecordAPI(ctx context.Context, event APIEvent) { + m.Called(ctx, event) +} + +func (m *MockRecorder) RecordStorage(ctx context.Context, event StorageEvent) { + m.Called(ctx, event) +} + +func TestSetEventRecorder(t *testing.T) { + t.Cleanup(func() { + SetEventRecorder(&NopRecorder{}) + }) + + ctx := context.Background() + now := time.Now() + apiEvent := APIEvent{ + RequestID: "request-id", + Method: "GET", + Path: "/api/recommend/user-id", + StatusCode: 200, + ResponseTime: 10, + Timestamp: now, + RemoteAddr: "127.0.0.1", + } + storageEvent := StorageEvent{ + UserCount: 1, + ItemCount: 2, + FeedbackCount: 3, + Timestamp: now, + } + + recorder := new(MockRecorder) + recorder.On("RecordAPI", ctx, apiEvent).Once() + recorder.On("RecordStorage", ctx, storageEvent).Once() + + SetEventRecorder(recorder) + + assert.Same(t, recorder, EventRecorder()) + EventRecorder().RecordAPI(ctx, apiEvent) + EventRecorder().RecordStorage(ctx, storageEvent) + + recorder.AssertExpectations(t) +} diff --git a/master/tasks.go b/master/tasks.go index 4bed3e942..c52fabc39 100644 --- a/master/tasks.go +++ b/master/tasks.go @@ -25,6 +25,7 @@ import ( "github.com/c-bata/goptuna" "github.com/c-bata/goptuna/tpe" mapset "github.com/deckarep/golang-set/v2" + "github.com/gorse-io/gorse/common/event" "github.com/gorse-io/gorse/common/expression" "github.com/gorse-io/gorse/common/log" "github.com/gorse-io/gorse/common/monitor" @@ -82,6 +83,12 @@ func (m *Master) loadDataset(parent context.Context) (datasets Datasets, err err if err != nil { return Datasets{}, errors.Trace(err) } + go event.EventRecorder().RecordStorage(ctx, event.StorageEvent{ + UserCount: datasets.rankingDataset.CountUsers(), + ItemCount: datasets.rankingDataset.CountItems(), + FeedbackCount: len(datasets.clickDataset.Target), + Timestamp: datasets.rankingDataset.GetTimestamp(), + }) // save non-personalized recommenders to cache for i, recommender := range nonPersonalizedRecommenders { diff --git a/server/rest.go b/server/rest.go index 0aa066402..7515aa9e4 100644 --- a/server/rest.go +++ b/server/rest.go @@ -29,6 +29,7 @@ import ( restfulspec "github.com/emicklei/go-restful-openapi/v2" "github.com/emicklei/go-restful/v3" "github.com/google/uuid" + "github.com/gorse-io/gorse/common/event" "github.com/gorse-io/gorse/common/expression" "github.com/gorse-io/gorse/common/heap" "github.com/gorse-io/gorse/common/log" @@ -131,6 +132,8 @@ func (s *RestServer) LogFilter(req *restful.Request, resp *restful.Response, cha start := time.Now() chain.ProcessFilter(req, resp) responseTime := time.Since(start) + + // Log access if !s.DisableLog && req.Request.URL.Path != "/api/dashboard/cluster" && req.Request.URL.Path != "/api/dashboard/tasks" { log.AccessLogger().Info(fmt.Sprintf("%s %s", req.Request.Method, req.Request.URL.Path), @@ -138,6 +141,15 @@ func (s *RestServer) LogFilter(req *restful.Request, resp *restful.Response, cha zap.Int("status_code", resp.StatusCode()), zap.Duration("response_time", responseTime), zap.String("remote_addr", req.Request.RemoteAddr)) + go event.EventRecorder().RecordAPI(req.Request.Context(), event.APIEvent{ + RequestID: requestId, + Method: req.Request.Method, + Path: req.Request.URL.Path, + StatusCode: resp.StatusCode(), + ResponseTime: responseTime.Milliseconds(), + Timestamp: start, + RemoteAddr: req.Request.RemoteAddr, + }) } }