From e018bedca3969802224e67295859f1026b6a2e9b Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 6 Jan 2026 14:55:21 +0100 Subject: [PATCH] Add go sdk --- .gitignore | 1 + LICENSE | 21 ++ builtinevents/builtinevents.go | 34 ++++ client/client.go | 124 ++++++++++++ client/client_test.go | 151 +++++++++++++++ client/errors.go | 8 + client/events.go | 46 +++++ client/events_test.go | 74 +++++++ client/model.go | 38 ++++ client/records.go | 46 +++++ go.mod | 8 + go.sum | 2 + httpmetrics/httpmetrics.go | 233 +++++++++++++++++++++++ httpmetrics/model.go | 34 ++++ metriccollector/metriccollector.go | 91 +++++++++ metriccollector/model.go | 5 + performancemetrics/cpu.go | 26 +++ performancemetrics/disk.go | 16 ++ performancemetrics/memory.go | 9 + performancemetrics/model.go | 15 ++ performancemetrics/performancemetrics.go | 108 +++++++++++ 21 files changed, 1090 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 builtinevents/builtinevents.go create mode 100644 client/client.go create mode 100644 client/client_test.go create mode 100644 client/errors.go create mode 100644 client/events.go create mode 100644 client/events_test.go create mode 100644 client/model.go create mode 100644 client/records.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 httpmetrics/httpmetrics.go create mode 100644 httpmetrics/model.go create mode 100644 metriccollector/metriccollector.go create mode 100644 metriccollector/model.go create mode 100644 performancemetrics/cpu.go create mode 100644 performancemetrics/disk.go create mode 100644 performancemetrics/memory.go create mode 100644 performancemetrics/model.go create mode 100644 performancemetrics/performancemetrics.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cc5c2ac --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Oliver Schlüter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/builtinevents/builtinevents.go b/builtinevents/builtinevents.go new file mode 100644 index 0000000..db72023 --- /dev/null +++ b/builtinevents/builtinevents.go @@ -0,0 +1,34 @@ +package builtinevents + +import ( + "log/slog" + + "github.com/OliverSchlueter/goutils/sloki" + "github.com/fancyinnovations/fancyanalytics/integrations/go-sdk/client" +) + +func ServiceStarted(service string) { + evt := &client.Event{ + Name: "ServiceStarted", + Properties: map[string]string{ + "service": service, + }, + } + + if err := client.DefaultClient.SendEvent(evt); err != nil { + slog.Warn("failed to send ServiceStarted event", sloki.WrapError(err)) + } +} + +func ServiceStopped(service string) { + evt := &client.Event{ + Name: "ServiceStopped", + Properties: map[string]string{ + "service": service, + }, + } + + if err := client.DefaultClient.SendEvent(evt); err != nil { + slog.Warn("failed to send ServiceStopped event", sloki.WrapError(err)) + } +} diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..c85d58d --- /dev/null +++ b/client/client.go @@ -0,0 +1,124 @@ +// Package client provides a client for the FancyAnalytics API. +package client + +import ( + "net/http" + "time" + + "github.com/google/uuid" +) + +const ( + DefaultBaseURL = "https://fancyanalytics.net" + DefaultMaxIdleConnections = 100 + DefaultIdleTimeout = 90 * time.Second + DefaultTimeout = 30 * time.Second +) + +var DefaultClient *Client + +// Client is a client for the FancyAnalytics API. +type Client struct { + baseURL string + authToken string + senderID string + projectID string + writeKey string + client *http.Client +} + +// Configuration holds the configuration for the FancyAnalytics client. +type Configuration struct { + // BaseURL is the base URL of the FancyAnalytics API. + // If empty, DefaultBaseURL is used. + BaseURL string + + // SenderID is the sender ID to use for requests. + // If empty a random UUID will be used. + SenderID string + + // AuthToken is the authentication token to use for requests. Optional. + AuthToken string + + // ProjectID is the project ID to use for requests. + ProjectID string + + // WriteKey is the write key for the project. Optional. + WriteKey string + + // MaxIdleConnections sets the maximum number of idle (keep-alive) connections across all hosts. + // If zero, DefaultMaxIdleConnections is used. + MaxIdleConnections int + + // IdleConnectionTimeout is the maximum amount of time an idle (keep-alive) connection will remain idle before closing itself. + // If zero, DefaultIdleTimeout is used. + IdleConnectionTimeout time.Duration + + // RequestTimeout is the maximum amount of time a request can take before timing out. + // If zero, DefaultTimeout is used. + RequestTimeout time.Duration +} + +// New creates a new FancyAnalytics client with the given configuration. +func New(cfg Configuration) (*Client, error) { + if cfg.ProjectID == "" { + return nil, ErrNoProjectID + } + + if cfg.BaseURL == "" { + cfg.BaseURL = DefaultBaseURL + } + + if cfg.SenderID == "" { + cfg.SenderID = uuid.New().String() + } + + if cfg.MaxIdleConnections == 0 { + cfg.MaxIdleConnections = DefaultMaxIdleConnections + } + + if cfg.IdleConnectionTimeout == 0 { + cfg.IdleConnectionTimeout = DefaultIdleTimeout + } + + if cfg.RequestTimeout == 0 { + cfg.RequestTimeout = DefaultTimeout + } + + transport := http.Transport{ + MaxIdleConns: cfg.MaxIdleConnections, + IdleConnTimeout: cfg.IdleConnectionTimeout, + DisableKeepAlives: false, + DisableCompression: false, + } + + c := &http.Client{ + Transport: &transport, + Timeout: cfg.RequestTimeout, + } + + return &Client{ + baseURL: cfg.BaseURL, + authToken: cfg.AuthToken, + projectID: cfg.ProjectID, + writeKey: cfg.WriteKey, + senderID: cfg.SenderID, + client: c, + }, nil +} + +// Ping checks if the FancyAnalytics API is reachable. +func (c *Client) Ping() error { + resp, err := c.client.Get(c.baseURL + "/collector/api/v1/ping") + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return ErrUnexpectedStatusCode + } + + return nil +} diff --git a/client/client_test.go b/client/client_test.go new file mode 100644 index 0000000..472e874 --- /dev/null +++ b/client/client_test.go @@ -0,0 +1,151 @@ +package client + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestNew(t *testing.T) { + for _, tc := range []struct { + name string + cfg Configuration + exp *Client + expErr error + }{ + { + name: "default values", + cfg: Configuration{ + AuthToken: "my-token", + ProjectID: "my-project", + }, + expErr: nil, + }, + { + name: "all values set", + cfg: Configuration{ + BaseURL: "https://custom-url.com/", + AuthToken: "my-token", + ProjectID: "my-project", + SenderID: "custom-sender-id", + MaxIdleConnections: 50, + IdleConnectionTimeout: 60 * time.Second, + RequestTimeout: 15 * time.Second, + }, + expErr: nil, + }, + { + name: "missing project ID", + cfg: Configuration{ + AuthToken: "my-token", + }, + expErr: ErrNoProjectID, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := New(tc.cfg) + if !errors.Is(err, tc.expErr) { + t.Errorf("expected error %v, got %v", tc.expErr, err) + } + + // If an error was expected, no need to check further. + if tc.expErr != nil { + return + } + + if tc.cfg.BaseURL == "" { + if got.baseURL != DefaultBaseURL { + t.Errorf("expected baseURL %s, got %s", DefaultBaseURL, got.baseURL) + } + } else if got.baseURL != tc.cfg.BaseURL { + t.Errorf("expected baseURL %s, got %s", tc.cfg.BaseURL, got.baseURL) + } + + if got.authToken != tc.cfg.AuthToken { + t.Errorf("expected authToken %s, got %s", tc.cfg.AuthToken, got.authToken) + } + + if got.projectID != tc.cfg.ProjectID { + t.Errorf("expected projectID %s, got %s", tc.cfg.ProjectID, got.projectID) + } + + if tc.cfg.SenderID == "" { + if got.senderID == "" { + t.Error("expected non-empty senderID, got empty") + } + } else if got.senderID != tc.cfg.SenderID { + t.Errorf("expected senderID %s, got %s", tc.cfg.SenderID, got.senderID) + } + + if tc.cfg.MaxIdleConnections == 0 { + if got.client.Transport.(*http.Transport).MaxIdleConns != DefaultMaxIdleConnections { + t.Errorf("expected MaxIdleConnections %d, got %d", DefaultMaxIdleConnections, got.client.Transport.(*http.Transport).MaxIdleConns) + } + } else if got.client.Transport.(*http.Transport).MaxIdleConns != tc.cfg.MaxIdleConnections { + t.Errorf("expected MaxIdleConnections %d, got %d", tc.cfg.MaxIdleConnections, got.client.Transport.(*http.Transport).MaxIdleConns) + } + }) + } +} + +func TestClient_Ping(t *testing.T) { + for _, tc := range []struct { + name string + statusCode int + serverError bool + expErr error + }{ + { + name: "successful ping", + statusCode: http.StatusOK, + expErr: nil, + }, + { + name: "unexpected status code", + statusCode: http.StatusInternalServerError, + expErr: ErrUnexpectedStatusCode, + }, + { + name: "network error", + serverError: true, + expErr: errors.New("network error"), + }, + } { + t.Run(tc.name, func(t *testing.T) { + var server *httptest.Server + if tc.serverError { + // Simulate network error by using a closed server + server = httptest.NewServer(http.NotFoundHandler()) + server.Close() + } else { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.statusCode) + })) + } + + client, err := New(Configuration{ + BaseURL: server.URL, + AuthToken: "test-token", + ProjectID: "test-project", + }) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + err = client.Ping() + if tc.expErr == nil { + if err != nil { + t.Errorf("expected no error, got %v", err) + } + } else if tc.serverError { + if err == nil { + t.Errorf("expected network error, got nil") + } + } else if !errors.Is(err, tc.expErr) { + t.Errorf("expected error %v, got %v", tc.expErr, err) + } + }) + } +} diff --git a/client/errors.go b/client/errors.go new file mode 100644 index 0000000..5b1c5b7 --- /dev/null +++ b/client/errors.go @@ -0,0 +1,8 @@ +package client + +import "errors" + +var ( + ErrNoProjectID = errors.New("no project ID provided") + ErrUnexpectedStatusCode = errors.New("unexpected status code") +) diff --git a/client/events.go b/client/events.go new file mode 100644 index 0000000..59f0772 --- /dev/null +++ b/client/events.go @@ -0,0 +1,46 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" +) + +func (c *Client) SendEvent(evt *Event) error { + dto := createEventDTO{ + ProjectID: c.projectID, + Name: evt.Name, + Timestamp: time.Now(), + Properties: evt.Properties, + WriteKey: c.writeKey, + } + + body, err := json.Marshal(dto) + if err != nil { + return fmt.Errorf("could not marshal event: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, c.baseURL+"/collector/api/v1/events", bytes.NewBuffer(body)) + if err != nil { + return fmt.Errorf("could not create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + if c.authToken != "" { + req.Header.Set("Authorization", "Bearer "+c.authToken) + } + + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("could not send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return ErrUnexpectedStatusCode + } + + return nil +} diff --git a/client/events_test.go b/client/events_test.go new file mode 100644 index 0000000..f354ec0 --- /dev/null +++ b/client/events_test.go @@ -0,0 +1,74 @@ +package client + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClient_SendEvent(t *testing.T) { + for _, tc := range []struct { + name string + statusCode int + serverError bool + expErr error + }{ + { + name: "successful event send", + statusCode: http.StatusOK, + expErr: nil, + }, + { + name: "unexpected status code", + statusCode: http.StatusBadRequest, + expErr: ErrUnexpectedStatusCode, + }, + { + name: "network error", + serverError: true, + expErr: errors.New("network error"), + }, + } { + t.Run(tc.name, func(t *testing.T) { + var server *httptest.Server + if tc.serverError { + server = httptest.NewServer(http.NotFoundHandler()) + server.Close() + } else { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.statusCode) + })) + } + + c, err := New(Configuration{ + BaseURL: server.URL, + AuthToken: "token", + ProjectID: "project", + }) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + e := &Event{ + Name: "test_event", + Properties: map[string]string{ + "key": "value", + }, + } + + err = c.SendEvent(e) + if tc.expErr == nil { + if err != nil { + t.Errorf("expected no error, got %v", err) + } + } else if tc.serverError { + if err == nil { + t.Errorf("expected network error, got nil") + } + } else if !errors.Is(err, tc.expErr) { + t.Errorf("expected error %v, got %v", tc.expErr, err) + } + }) + } +} diff --git a/client/model.go b/client/model.go new file mode 100644 index 0000000..373b542 --- /dev/null +++ b/client/model.go @@ -0,0 +1,38 @@ +package client + +import "time" + +type Event struct { + Name string `json:"name"` + Properties map[string]string `json:"properties"` +} + +func (e *Event) WithProperty(key, value string) { + if e.Properties == nil { + e.Properties = make(map[string]string) + } + + e.Properties[key] = value +} + +type createEventDTO struct { + ProjectID string `json:"project_id"` + Name string `json:"name"` + Timestamp time.Time `json:"timestamp,omitempty"` + Properties map[string]string `json:"properties"` + WriteKey string `json:"write_key,omitempty"` +} + +type RecordData struct { + Metric string `json:"metric"` + Label string `json:"label,omitempty"` + Value float64 `json:"value"` +} + +type createMetricRecordDto struct { + SenderID string `json:"sender_id"` + ProjectID string `json:"project_id"` + Timestamp int64 `json:"timestamp,omitempty"` + WriteKey string `json:"write_key,omitempty"` + Data []RecordData `json:"data"` +} diff --git a/client/records.go b/client/records.go new file mode 100644 index 0000000..3e5e054 --- /dev/null +++ b/client/records.go @@ -0,0 +1,46 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" +) + +func (c *Client) SendRecord(records []RecordData) error { + dto := createMetricRecordDto{ + SenderID: c.senderID, + ProjectID: c.projectID, + Timestamp: time.Now().Unix(), + WriteKey: c.writeKey, + Data: records, + } + + body, err := json.Marshal(dto) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, c.baseURL+"/collector/api/v1/records", bytes.NewBuffer(body)) + if err != nil { + return fmt.Errorf("could not create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + if c.authToken != "" { + req.Header.Set("Authorization", "Bearer "+c.authToken) + } + + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("could not send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return ErrUnexpectedStatusCode + } + + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..80639f2 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module git.fancyinnovations.com/fancyanalytics/go-sdk + +go 1.25.5 + +require ( + github.com/OliverSchlueter/goutils v0.0.24 + github.com/google/uuid v1.6.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..be70e2a --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/OliverSchlueter/goutils v0.0.24/go.mod h1:Kf8k9AaaATGm6D/IVJkIfVZ4sUTXzHfy/cLQdpejFXE= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/httpmetrics/httpmetrics.go b/httpmetrics/httpmetrics.go new file mode 100644 index 0000000..536825c --- /dev/null +++ b/httpmetrics/httpmetrics.go @@ -0,0 +1,233 @@ +package httpmetrics + +import ( + "net/http" + "sync" + "time" + + "github.com/fancyinnovations/fancyanalytics/integrations/go-sdk/client" +) + +type Service struct { + lastReset time.Time + statusCodes map[string]int64 + requestSizes []int64 + responseSizes []int64 + durations []int64 + mu sync.Mutex +} + +func NewService() *Service { + s := &Service{} + s.resetMetrics() + + return s +} + +func (s *Service) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + + sr := &StatusRecorder{ + ResponseWriter: w, + Status: http.StatusOK, + } + + next.ServeHTTP(sr, r) + + duration := time.Since(startTime).Milliseconds() + + s.mu.Lock() + defer s.mu.Unlock() + s.durations = append(s.durations, duration) + s.requestSizes = append(s.requestSizes, r.ContentLength) + s.responseSizes = append(s.responseSizes, sr.Size) + + statusCodeCategory := sr.Status / 100 + switch statusCodeCategory { + case 2: + s.statusCodes["2xx"]++ + case 3: + s.statusCodes["3xx"]++ + case 4: + s.statusCodes["4xx"]++ + case 5: + s.statusCodes["5xx"]++ + } + }) +} + +func (s *Service) collectAndReset() RequestMetrics { + s.mu.Lock() + defer s.mu.Unlock() + + // duration stats + var totalDuration int64 + var minDuration int64 = -1 + var maxDuration int64 = -1 + + for _, d := range s.durations { + totalDuration += d + if minDuration == -1 || d < minDuration { + minDuration = d + } + if d > maxDuration { + maxDuration = d + } + } + + var avgDuration float64 + if len(s.durations) > 0 { + avgDuration = float64(totalDuration) / float64(len(s.durations)) + } + + // request size stats + var totalRequestSize int64 + var minRequestSize int64 = -1 + var maxRequestSize int64 = -1 + + for _, rs := range s.requestSizes { + totalRequestSize += rs + if minRequestSize == -1 || rs < minRequestSize { + minRequestSize = rs + } + if rs > maxRequestSize { + maxRequestSize = rs + } + } + + var avgRequestSize float64 + if len(s.requestSizes) > 0 { + avgRequestSize = float64(totalRequestSize) / float64(len(s.requestSizes)) + } + + // response size stats + var totalResponseSize int64 + var minResponseSize int64 = -1 + var maxResponseSize int64 = -1 + + for _, rs := range s.responseSizes { + totalResponseSize += rs + if minResponseSize == -1 || rs < minResponseSize { + minResponseSize = rs + } + if rs > maxResponseSize { + maxResponseSize = rs + } + } + + var avgResponseSize float64 + if len(s.responseSizes) > 0 { + avgResponseSize = float64(totalResponseSize) / float64(len(s.responseSizes)) + } + + // requests per second + rps := float64(len(s.durations)) / time.Since(s.lastReset).Seconds() + + metrics := RequestMetrics{ + RequestsPerSecond: rps, + StatusCodes: s.statusCodes, + } + metrics.Durations.Min = minDuration + metrics.Durations.Max = maxDuration + metrics.Durations.Avg = avgDuration + metrics.RequestSizes.Min = minRequestSize + metrics.RequestSizes.Max = maxRequestSize + metrics.RequestSizes.Avg = avgRequestSize + metrics.ResponseSizes.Min = minResponseSize + metrics.ResponseSizes.Max = maxResponseSize + metrics.ResponseSizes.Avg = avgResponseSize + + s.resetMetrics() + + return metrics +} + +func (s *Service) resetMetrics() { + s.statusCodes = map[string]int64{ + "2xx": 0, + "3xx": 0, + "4xx": 0, + "5xx": 0, + } + s.durations = []int64{} + s.requestSizes = []int64{} + s.responseSizes = []int64{} + s.lastReset = time.Now() +} + +func (s *Service) MetricProvider() ([]client.RecordData, error) { + metrics := s.collectAndReset() + + records := []client.RecordData{ + { + Metric: "requests_per_second", + Label: "", + Value: metrics.RequestsPerSecond, + }, + } + + records = append(records, + client.RecordData{ + Metric: "min_request_duration", + Label: "", + Value: float64(metrics.Durations.Min), + }, + client.RecordData{ + Metric: "max_request_duration", + Label: "", + Value: float64(metrics.Durations.Max), + }, + client.RecordData{ + Metric: "avg_request_duration", + Label: "", + Value: metrics.Durations.Avg, + }, + ) + + records = append(records, + client.RecordData{ + Metric: "min_request_size", + Label: "", + Value: float64(metrics.RequestSizes.Min), + }, + client.RecordData{ + Metric: "max_request_size", + Label: "", + Value: float64(metrics.RequestSizes.Max), + }, + client.RecordData{ + Metric: "avg_request_size", + Label: "", + Value: metrics.RequestSizes.Avg, + }, + ) + + records = append(records, + client.RecordData{ + Metric: "min_response_size", + Label: "", + Value: float64(metrics.ResponseSizes.Min), + }, + client.RecordData{ + Metric: "max_response_size", + Label: "", + Value: float64(metrics.ResponseSizes.Max), + }, + client.RecordData{ + Metric: "avg_response_size", + Label: "", + Value: metrics.ResponseSizes.Avg, + }, + ) + + for status, count := range metrics.StatusCodes { + records = append(records, client.RecordData{ + Metric: "status_code_count", + Label: status, + Value: float64(count), + }) + } + + return records, nil +} diff --git a/httpmetrics/model.go b/httpmetrics/model.go new file mode 100644 index 0000000..da3da89 --- /dev/null +++ b/httpmetrics/model.go @@ -0,0 +1,34 @@ +package httpmetrics + +import "net/http" + +type StatusRecorder struct { + http.ResponseWriter + Status int + Size int64 +} + +func (s *StatusRecorder) WriteHeader(code int) { + s.Status = code + s.ResponseWriter.WriteHeader(code) +} + +func (s *StatusRecorder) Write(b []byte) (int, error) { + n, err := s.ResponseWriter.Write(b) + s.Size += int64(n) + return n, err +} + +type RequestMetrics struct { + RequestsPerSecond float64 + StatusCodes map[string]int64 + Durations Stats + RequestSizes Stats + ResponseSizes Stats +} + +type Stats struct { + Min int64 + Max int64 + Avg float64 +} diff --git a/metriccollector/metriccollector.go b/metriccollector/metriccollector.go new file mode 100644 index 0000000..0d3d131 --- /dev/null +++ b/metriccollector/metriccollector.go @@ -0,0 +1,91 @@ +package metriccollector + +import ( + "log/slog" + "time" + + "github.com/OliverSchlueter/goutils/sloki" + "github.com/fancyinnovations/fancyanalytics/integrations/go-sdk/client" +) + +type Service struct { + c *client.Client + interval time.Duration + providers []MetricProvider + schedulerStarted bool + abortScheduler chan struct{} +} + +type Configuration struct { + Client *client.Client + Interval time.Duration + Providers []MetricProvider +} + +func NewService(cfg *Configuration) *Service { + if cfg.Interval <= 0 { + cfg.Interval = 60 + } + + if cfg.Providers == nil { + cfg.Providers = []MetricProvider{} + } + + return &Service{ + c: cfg.Client, + interval: cfg.Interval, + providers: cfg.Providers, + schedulerStarted: false, + abortScheduler: make(chan struct{}), + } +} + +func (s *Service) AddProvider(provider MetricProvider) { + s.providers = append(s.providers, provider) +} + +func (s *Service) Send() error { + var records []client.RecordData + for _, provider := range s.providers { + providerRecords, err := provider() + if err != nil { + continue + } + records = append(records, providerRecords...) + } + + if err := s.c.SendRecord(records); err != nil { + return err + } + + return nil +} + +func (s *Service) StartScheduler() { + if s.schedulerStarted { + return + } + + s.schedulerStarted = true + + go func() { + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := s.Send(); err != nil { + slog.Warn("failed to send metrics", sloki.WrapError(err)) + } + case <-s.abortScheduler: + return + } + } + }() +} + +func (s *Service) StopScheduler() { + s.abortScheduler <- struct{}{} + s.schedulerStarted = false +} diff --git a/metriccollector/model.go b/metriccollector/model.go new file mode 100644 index 0000000..6b0e34e --- /dev/null +++ b/metriccollector/model.go @@ -0,0 +1,5 @@ +package metriccollector + +import "github.com/fancyinnovations/fancyanalytics/integrations/go-sdk/client" + +type MetricProvider func() ([]client.RecordData, error) diff --git a/performancemetrics/cpu.go b/performancemetrics/cpu.go new file mode 100644 index 0000000..d4fd99a --- /dev/null +++ b/performancemetrics/cpu.go @@ -0,0 +1,26 @@ +package performancemetrics + +import ( + "os" + "syscall" +) + +func getCpuTime() (float64, error) { + _, err := os.FindProcess(os.Getpid()) + if err != nil { + return 0, err + } + + // Use syscall to get CPU times + var ru syscall.Rusage + err = syscall.Getrusage(syscall.RUSAGE_SELF, &ru) + if err != nil { + return 0, err + } + + // Total CPU time in seconds + userSec := float64(ru.Utime.Sec) + float64(ru.Utime.Usec)/1e6 + sysSec := float64(ru.Stime.Sec) + float64(ru.Stime.Usec)/1e6 + + return userSec + sysSec, nil +} diff --git a/performancemetrics/disk.go b/performancemetrics/disk.go new file mode 100644 index 0000000..1fc97f6 --- /dev/null +++ b/performancemetrics/disk.go @@ -0,0 +1,16 @@ +package performancemetrics + +import "syscall" + +func getDiskUsage(path string) (used, free, total uint64, err error) { + var stat syscall.Statfs_t + err = syscall.Statfs(path, &stat) + if err != nil { + return + } + + total = stat.Blocks * uint64(stat.Bsize) + free = stat.Bfree * uint64(stat.Bsize) + used = total - free + return +} diff --git a/performancemetrics/memory.go b/performancemetrics/memory.go new file mode 100644 index 0000000..1037840 --- /dev/null +++ b/performancemetrics/memory.go @@ -0,0 +1,9 @@ +package performancemetrics + +import "runtime" + +func getAllocatedMemory() uint64 { + var m runtime.MemStats + runtime.ReadMemStats(&m) + return m.Alloc +} diff --git a/performancemetrics/model.go b/performancemetrics/model.go new file mode 100644 index 0000000..89b3190 --- /dev/null +++ b/performancemetrics/model.go @@ -0,0 +1,15 @@ +package performancemetrics + +type PerformanceMetrics struct { + Uptime int64 + CpuUsage float64 + AllocatedMemory uint64 + GoroutineCount int + DiskUsage DiskUsage +} + +type DiskUsage struct { + Used uint64 + Free uint64 + Total uint64 +} diff --git a/performancemetrics/performancemetrics.go b/performancemetrics/performancemetrics.go new file mode 100644 index 0000000..682cbc2 --- /dev/null +++ b/performancemetrics/performancemetrics.go @@ -0,0 +1,108 @@ +package performancemetrics + +import ( + "runtime" + "time" + + "github.com/fancyinnovations/fancyanalytics/integrations/go-sdk/client" +) + +type Service struct { + appStartTime time.Time + lastReset time.Time +} + +func NewService() *Service { + s := &Service{ + appStartTime: time.Now(), + } + s.resetMetrics() + + return s +} + +func (s *Service) resetMetrics() { + s.lastReset = time.Now() +} + +func (s *Service) collectAndReset() (*PerformanceMetrics, error) { + cpuTime, err := getCpuTime() + if err != nil { + return nil, err + } + cpuUsage := cpuTime / time.Since(s.lastReset).Seconds() + + goroutineCount := runtime.NumGoroutine() + + allocedMemory := getAllocatedMemory() + + usedDisk, freeDisk, totalDisk, err := getDiskUsage("/") + if err != nil { + return nil, err + } + + uptimeMs := time.Since(s.appStartTime).Milliseconds() + + metrics := &PerformanceMetrics{ + Uptime: uptimeMs, + CpuUsage: cpuUsage, + AllocatedMemory: allocedMemory, + GoroutineCount: goroutineCount, + DiskUsage: DiskUsage{ + Used: usedDisk, + Free: freeDisk, + Total: totalDisk, + }, + } + + s.resetMetrics() + + return metrics, nil +} + +func (s *Service) MetricProvider() ([]client.RecordData, error) { + metrics, err := s.collectAndReset() + if err != nil { + return nil, err + } + + records := []client.RecordData{ + { + Metric: "uptime", + Label: "", + Value: float64(metrics.Uptime), + }, + { + Metric: "cpu_usage", + Label: "", + Value: metrics.CpuUsage, + }, + { + Metric: "allocated_memory", + Label: "", + Value: float64(metrics.AllocatedMemory), + }, + { + Metric: "goroutine_count", + Label: "", + Value: float64(metrics.GoroutineCount), + }, + { + Metric: "disk_usage_total", + Label: "", + Value: float64(metrics.DiskUsage.Total), + }, + { + Metric: "disk_usage", + Label: "free", + Value: float64(metrics.DiskUsage.Free), + }, + { + Metric: "disk_usage", + Label: "used", + Value: float64(metrics.DiskUsage.Used), + }, + } + + return records, nil +}