Przeglądaj źródła

Add ingest endpoint to metronome client (#4531)

Co-authored-by: jusrhee <justin@porter.run>
Mauricio Araujo 2 lat temu
rodzic
commit
f0d8dc425c

+ 68 - 0
api/server/handlers/billing/ingest.go

@@ -0,0 +1,68 @@
+// NewGetUsageDashboardHandler returns a new GetUsageDashboardHandler
+package billing
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// IngestEventsHandler is a handler for ingesting billing events
+type IngestEventsHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewIngestEventsHandler returns a new IngestEventsHandler
+func NewIngestEventsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *IngestEventsHandler {
+	return &IngestEventsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *IngestEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-ingest-events")
+	defer span.End()
+
+	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	if !c.Config().BillingManager.MetronomeEnabled || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
+		c.WriteResult(w, r, "")
+
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "metronome-enabled", Value: false},
+		)
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "metronome-enabled", Value: true},
+		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
+	)
+
+	request := []types.BillingEvent{}
+
+	if ok := c.DecodeAndValidate(w, r, &request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding ingest events request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	err := c.Config().BillingManager.MetronomeClient.IngestEvents(ctx, request)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error ingesting events")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, "")
+}

+ 1 - 1
api/server/handlers/billing/plan.go

@@ -119,7 +119,7 @@ func NewGetUsageDashboardHandler(
 }
 
 func (c *GetUsageDashboardHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ctx, span := telemetry.NewSpan(r.Context(), "get-usage-dashboard-endpoint")
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-usage-dashboard")
 	defer span.End()
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)

+ 29 - 1
api/server/router/project.go

@@ -395,7 +395,7 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/billing/dashboard -> project.NewGetUsageDashboardHandler
+	// POST /api/projects/{project_id}/billing/dashboard -> project.NewGetUsageDashboardHandler
 	getUsageDashboardEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
@@ -423,6 +423,34 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/billing/ingest -> project.NewGetUsageDashboardHandler
+	ingestEventsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/billing/ingest",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	ingestEventsHandler := billing.NewIngestEventsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: ingestEventsEndpoint,
+		Handler:  ingestEventsHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/billing/payment_method -> project.NewCreateBillingHandler
 	createBillingEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 9 - 0
api/types/billing_metronome.go

@@ -140,3 +140,12 @@ type ColorOverride struct {
 	Name  string `json:"name"`
 	Value string `json:"value"`
 }
+
+// BillingEvent represents a Metronome billing event.
+type BillingEvent struct {
+	CustomerID    string                 `json:"customer_id"`
+	EventType     string                 `json:"event_type"`
+	Properties    map[string]interface{} `json:"properties"`
+	TransactionID string                 `json:"transaction_id"`
+	Timestamp     string                 `json:"timestamp"`
+}

+ 51 - 21
internal/billing/metronome.go

@@ -16,11 +16,9 @@ import (
 )
 
 const (
-	metronomeBaseUrl         = "https://api.metronome.com/v1/"
-	defaultCollectionMethod  = "charge_automatically"
-	defaultGrantCredits      = 5000
-	defaultGrantName         = "Starter Credits"
-	defaultGrantExpiryMonths = 1
+	metronomeBaseUrl        = "https://api.metronome.com/v1/"
+	defaultCollectionMethod = "charge_automatically"
+	defaultMaxRetries       = 10
 )
 
 // MetronomeClient is the client used to call the Metronome API
@@ -97,7 +95,7 @@ func (m MetronomeClient) createCustomer(ctx context.Context, userEmail string, p
 		Data types.Customer `json:"data"`
 	}
 
-	err = do(http.MethodPost, path, m.ApiKey, customer, &result)
+	_, err = m.do(http.MethodPost, path, customer, &result)
 	if err != nil {
 		return customerID, telemetry.Error(ctx, span, err, "error creating customer")
 	}
@@ -131,7 +129,7 @@ func (m MetronomeClient) addCustomerPlan(ctx context.Context, customerID uuid.UU
 		} `json:"data"`
 	}
 
-	err = do(http.MethodPost, path, m.ApiKey, req, &result)
+	_, err = m.do(http.MethodPost, path, req, &result)
 	if err != nil {
 		return customerPlanID, telemetry.Error(ctx, span, err, "failed to add customer to plan")
 	}
@@ -154,7 +152,7 @@ func (m MetronomeClient) ListCustomerPlan(ctx context.Context, customerID uuid.U
 		Data []types.Plan `json:"data"`
 	}
 
-	err = do(http.MethodGet, path, m.ApiKey, nil, &result)
+	_, err = m.do(http.MethodGet, path, nil, &result)
 	if err != nil {
 		return plan, telemetry.Error(ctx, span, err, "failed to list customer plans")
 	}
@@ -186,7 +184,7 @@ func (m MetronomeClient) EndCustomerPlan(ctx context.Context, customerID uuid.UU
 		EndingBeforeUTC: endBefore,
 	}
 
-	err = do(http.MethodPost, path, m.ApiKey, req, nil)
+	_, err = m.do(http.MethodPost, path, req, nil)
 	if err != nil {
 		return telemetry.Error(ctx, span, err, "failed to end customer plan")
 	}
@@ -215,7 +213,7 @@ func (m MetronomeClient) ListCustomerCredits(ctx context.Context, customerID uui
 		Data []types.CreditGrant `json:"data"`
 	}
 
-	err = do(http.MethodPost, path, m.ApiKey, req, &result)
+	_, err = m.do(http.MethodPost, path, req, &result)
 	if err != nil {
 		return credits, telemetry.Error(ctx, span, err, "failed to list customer credits")
 	}
@@ -251,7 +249,7 @@ func (m MetronomeClient) GetCustomerDashboard(ctx context.Context, customerID uu
 		Data map[string]string `json:"data"`
 	}
 
-	err = do(http.MethodPost, path, m.ApiKey, req, &result)
+	_, err = m.do(http.MethodPost, path, req, &result)
 	if err != nil {
 		return url, telemetry.Error(ctx, span, err, "failed to get embeddable dashboard")
 	}
@@ -259,53 +257,85 @@ func (m MetronomeClient) GetCustomerDashboard(ctx context.Context, customerID uu
 	return result.Data["url"], nil
 }
 
-func do(method string, path string, apiKey string, body interface{}, data interface{}) (err error) {
+// IngestEvents sends a list of billing events to Metronome's ingest endpoint
+func (m MetronomeClient) IngestEvents(ctx context.Context, events []types.BillingEvent) (err error) {
+	path := "ingest"
+
+	var currentAttempts int
+	for currentAttempts < defaultMaxRetries {
+		statusCode, err := m.do(http.MethodPost, path, events, nil)
+		// Check errors that are not from error http codes
+		if statusCode == 0 && err != nil {
+			return err
+		}
+
+		if statusCode == http.StatusForbidden || statusCode == http.StatusUnauthorized {
+			return fmt.Errorf("unauthorized")
+		}
+
+		// 400 responses should not be retried
+		if statusCode == http.StatusBadRequest {
+			return fmt.Errorf("malformed billing events")
+		}
+
+		// Any other status code can be safely retried
+		if statusCode == 200 {
+			return nil
+		}
+		currentAttempts++
+	}
+
+	return fmt.Errorf("max number of retry attempts reached with no success")
+}
+
+func (m MetronomeClient) do(method string, path string, body interface{}, data interface{}) (statusCode int, err error) {
 	client := http.Client{}
 	endpoint, err := url.JoinPath(metronomeBaseUrl, path)
 	if err != nil {
-		return err
+		return statusCode, err
 	}
 
 	var bodyJson []byte
 	if body != nil {
 		bodyJson, err = json.Marshal(body)
 		if err != nil {
-			return err
+			return statusCode, err
 		}
 	}
 
 	req, err := http.NewRequest(method, endpoint, bytes.NewBuffer(bodyJson))
 	if err != nil {
-		return err
+		return statusCode, err
 	}
-	bearer := "Bearer " + apiKey
+	bearer := "Bearer " + m.ApiKey
 	req.Header.Set("Authorization", bearer)
 	req.Header.Set("Content-Type", "application/json")
 
 	resp, err := client.Do(req)
 	if err != nil {
-		return err
+		return statusCode, err
 	}
+	statusCode = resp.StatusCode
 
 	if resp.StatusCode != http.StatusOK {
 		// If there is an error, try to decode the message
 		var message map[string]string
 		err = json.NewDecoder(resp.Body).Decode(&message)
 		if err != nil {
-			return fmt.Errorf("status code %d received, couldn't process response message", resp.StatusCode)
+			return statusCode, fmt.Errorf("status code %d received, couldn't process response message", resp.StatusCode)
 		}
 		_ = resp.Body.Close()
 
-		return fmt.Errorf("status code %d received, response message: %v", resp.StatusCode, message)
+		return statusCode, fmt.Errorf("status code %d received, response message: %v", resp.StatusCode, message)
 	}
 
 	if data != nil {
 		err = json.NewDecoder(resp.Body).Decode(data)
 		if err != nil {
-			return err
+			return statusCode, err
 		}
 	}
 	_ = resp.Body.Close()
 
-	return nil
+	return statusCode, nil
 }