Quellcode durchsuchen

Add endpoints for listing customer usage metrics

Mauricio Araujo vor 2 Jahren
Ursprung
Commit
1e31eac77f

+ 49 - 0
api/server/handlers/billing/plan.go

@@ -158,3 +158,52 @@ func (c *GetUsageDashboardHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 
 	c.WriteResult(w, r, credits)
 }
+
+// ListCustomerUsageHandler returns customer usage aggregations like CPU and RAM hours.
+type ListCustomerUsageHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewListCustomerUsageHandler returns a new ListCustomerUsageHandler
+func NewListCustomerUsageHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListCustomerUsageHandler {
+	return &ListCustomerUsageHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *ListCustomerUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-customer-usage")
+	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-config-exists", Value: c.Config().BillingManager.MetronomeEnabled},
+			telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
+		)
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "metronome-enabled", Value: true},
+		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
+	)
+
+	req := &types.ListCustomerUsageRequest{}
+
+	credits, err := c.Config().BillingManager.MetronomeClient.ListCustomerUsage(ctx, proj.UsageID, req.StartingOn, req.EndingBefore, req.WindowSize, req.CurrentPeriod)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error listing customer usage")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, credits)
+}

+ 28 - 0
api/server/router/project.go

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

+ 22 - 0
api/types/billing_metronome.go

@@ -78,6 +78,28 @@ type EmbeddableDashboardRequest struct {
 	ColorOverrides []ColorOverride `json:"color_overrides,omitempty"`
 }
 
+// ListCustomerUsageRequest is the request to list usage for a customer
+type ListCustomerUsageRequest struct {
+	CustomerID       uuid.UUID `json:"customer_id"`
+	BillableMetricID uuid.UUID `json:"billable_metric_id"`
+	WindowSize       string    `json:"window_size"`
+	StartingOn       string    `json:"starting_on"`
+	EndingBefore     string    `json:"ending_before"`
+	CurrentPeriod    bool      `json:"current_period"`
+}
+
+// Usage is the usage of a customer
+type Usage struct {
+	StartingOn   string  `json:"starting_on"`
+	EndingBefore string  `json:"ending_before"`
+	Value        float64 `json:"value"`
+}
+
+type BillableMetric struct {
+	ID   uuid.UUID `json:"id"`
+	Name string    `json:"name"`
+}
+
 // Plan is a pricing plan to which a user is currently subscribed
 type Plan struct {
 	ID                  uuid.UUID `json:"id"`

+ 87 - 6
internal/billing/metronome.go

@@ -24,6 +24,7 @@ const (
 // MetronomeClient is the client used to call the Metronome API
 type MetronomeClient struct {
 	ApiKey               string
+	billableMetricIDs    []uuid.UUID
 	PorterCloudPlanID    uuid.UUID
 	PorterStandardPlanID uuid.UUID
 }
@@ -194,14 +195,14 @@ func (m MetronomeClient) EndCustomerPlan(ctx context.Context, customerID uuid.UU
 
 // ListCustomerCredits will return the total number of credits for the customer
 func (m MetronomeClient) ListCustomerCredits(ctx context.Context, customerID uuid.UUID) (credits types.ListCreditGrantsResponse, err error) {
-	ctx, span := telemetry.NewSpan(ctx, "list-customer-credits")
+	ctx, span := telemetry.NewSpan(ctx, "list-customer-usage")
 	defer span.End()
 
 	if customerID == uuid.Nil {
 		return credits, telemetry.Error(ctx, span, err, "customer id empty")
 	}
 
-	path := "credits/listGrants"
+	path := "usage/groups"
 
 	req := types.ListCreditGrantsRequest{
 		CustomerIDs: []uuid.UUID{
@@ -257,8 +258,66 @@ func (m MetronomeClient) GetCustomerDashboard(ctx context.Context, customerID uu
 	return result.Data["url"], nil
 }
 
+func (m MetronomeClient) ListCustomerUsage(ctx context.Context, customerID uuid.UUID, startingOn string, endingBefore string, windowsSize string, currentPeriod bool) (usage []types.Usage, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "list-customer-usage")
+	defer span.End()
+
+	if customerID == uuid.Nil {
+		return usage, telemetry.Error(ctx, span, err, "customer id empty")
+	}
+
+	if len(m.billableMetricIDs) == 0 {
+		billableMetrics, err := m.listBillableMetricIDs(ctx, customerID)
+		if err != nil {
+			return nil, telemetry.Error(ctx, span, err, "failed to list billable metrics")
+		}
+
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "billable-metric-count", Value: len(billableMetrics)},
+		)
+
+		// Cache billable metric ids for future calls
+		for _, billableMetricID := range billableMetrics {
+			m.billableMetricIDs = append(m.billableMetricIDs, billableMetricID.ID)
+		}
+	}
+
+	path := "usage/groups"
+
+	baseReq := types.ListCustomerUsageRequest{
+		CustomerID:    customerID,
+		WindowSize:    windowsSize,
+		StartingOn:    startingOn,
+		EndingBefore:  endingBefore,
+		CurrentPeriod: currentPeriod,
+	}
+
+	for _, billableMetric := range m.billableMetricIDs {
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "billable-metric-id", Value: billableMetric.ID},
+		)
+
+		var result struct {
+			Data []types.Usage `json:"data"`
+		}
+
+		baseReq.BillableMetricID = billableMetric
+		_, err = m.do(http.MethodPost, path, baseReq, &result)
+		if err != nil {
+			return usage, telemetry.Error(ctx, span, err, "failed to get customer usage")
+		}
+
+		usage = append(usage, result.Data...)
+	}
+
+	return usage, nil
+}
+
 // IngestEvents sends a list of billing events to Metronome's ingest endpoint
 func (m MetronomeClient) IngestEvents(ctx context.Context, events []types.BillingEvent) (err error) {
+	ctx, span := telemetry.NewSpan(ctx, "ingets-billing-events")
+	defer span.End()
+
 	if len(events) == 0 {
 		return nil
 	}
@@ -270,16 +329,16 @@ func (m MetronomeClient) IngestEvents(ctx context.Context, events []types.Billin
 		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
+			return telemetry.Error(ctx, span, err, "failed to ingest billing events")
 		}
 
 		if statusCode == http.StatusForbidden || statusCode == http.StatusUnauthorized {
-			return fmt.Errorf("unauthorized")
+			return telemetry.Error(ctx, span, err, "unauthorized")
 		}
 
 		// 400 responses should not be retried
 		if statusCode == http.StatusBadRequest {
-			return fmt.Errorf("malformed billing events")
+			return telemetry.Error(ctx, span, err, "malformed billing events")
 		}
 
 		// Any other status code can be safely retried
@@ -289,7 +348,29 @@ func (m MetronomeClient) IngestEvents(ctx context.Context, events []types.Billin
 		currentAttempts++
 	}
 
-	return fmt.Errorf("max number of retry attempts reached with no success")
+	return telemetry.Error(ctx, span, err, "max number of retry attempts reached with no success")
+}
+
+func (m MetronomeClient) listBillableMetricIDs(ctx context.Context, customerID uuid.UUID) (billableMetrics []types.BillableMetric, err error) {
+	ctx, span := telemetry.NewSpan(ctx, "list-billable-metrics")
+	defer span.End()
+
+	if customerID == uuid.Nil {
+		return billableMetrics, telemetry.Error(ctx, span, err, "customer id empty")
+	}
+
+	path := fmt.Sprintf("/customers/%s/billable-metrics", customerID)
+
+	var result struct {
+		Data []types.BillableMetric `json:"data"`
+	}
+
+	_, err = m.do(http.MethodGet, path, nil, &result)
+	if err != nil {
+		return billableMetrics, telemetry.Error(ctx, span, err, "failed to retrieve billable metrics from metronome")
+	}
+
+	return result.Data, nil
 }
 
 func (m MetronomeClient) do(method string, path string, body interface{}, data interface{}) (statusCode int, err error) {