Skip to content

Commit f0d8dc4

Browse files
MauAraujojusrhee
andauthored
Add ingest endpoint to metronome client (#4531)
Co-authored-by: jusrhee <justin@porter.run>
1 parent a8a4328 commit f0d8dc4

File tree

5 files changed

+158
-23
lines changed

5 files changed

+158
-23
lines changed

api/server/handlers/billing/ingest.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// NewGetUsageDashboardHandler returns a new GetUsageDashboardHandler
2+
package billing
3+
4+
import (
5+
"net/http"
6+
7+
"github.com/porter-dev/porter/api/server/handlers"
8+
"github.com/porter-dev/porter/api/server/shared"
9+
"github.com/porter-dev/porter/api/server/shared/apierrors"
10+
"github.com/porter-dev/porter/api/server/shared/config"
11+
"github.com/porter-dev/porter/api/types"
12+
"github.com/porter-dev/porter/internal/models"
13+
"github.com/porter-dev/porter/internal/telemetry"
14+
)
15+
16+
// IngestEventsHandler is a handler for ingesting billing events
17+
type IngestEventsHandler struct {
18+
handlers.PorterHandlerReadWriter
19+
}
20+
21+
// NewIngestEventsHandler returns a new IngestEventsHandler
22+
func NewIngestEventsHandler(
23+
config *config.Config,
24+
decoderValidator shared.RequestDecoderValidator,
25+
writer shared.ResultWriter,
26+
) *IngestEventsHandler {
27+
return &IngestEventsHandler{
28+
PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
29+
}
30+
}
31+
32+
func (c *IngestEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
33+
ctx, span := telemetry.NewSpan(r.Context(), "serve-ingest-events")
34+
defer span.End()
35+
36+
proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
37+
38+
if !c.Config().BillingManager.MetronomeEnabled || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
39+
c.WriteResult(w, r, "")
40+
41+
telemetry.WithAttributes(span,
42+
telemetry.AttributeKV{Key: "metronome-enabled", Value: false},
43+
)
44+
return
45+
}
46+
47+
telemetry.WithAttributes(span,
48+
telemetry.AttributeKV{Key: "metronome-enabled", Value: true},
49+
telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
50+
)
51+
52+
request := []types.BillingEvent{}
53+
54+
if ok := c.DecodeAndValidate(w, r, &request); !ok {
55+
err := telemetry.Error(ctx, span, nil, "error decoding ingest events request")
56+
c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
57+
return
58+
}
59+
60+
err := c.Config().BillingManager.MetronomeClient.IngestEvents(ctx, request)
61+
if err != nil {
62+
err := telemetry.Error(ctx, span, err, "error ingesting events")
63+
c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
64+
return
65+
}
66+
67+
c.WriteResult(w, r, "")
68+
}

api/server/handlers/billing/plan.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ func NewGetUsageDashboardHandler(
119119
}
120120

121121
func (c *GetUsageDashboardHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
122-
ctx, span := telemetry.NewSpan(r.Context(), "get-usage-dashboard-endpoint")
122+
ctx, span := telemetry.NewSpan(r.Context(), "serve-usage-dashboard")
123123
defer span.End()
124124

125125
proj, _ := ctx.Value(types.ProjectScope).(*models.Project)

api/server/router/project.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ func getProjectRoutes(
395395
Router: r,
396396
})
397397

398-
// GET /api/projects/{project_id}/billing/dashboard -> project.NewGetUsageDashboardHandler
398+
// POST /api/projects/{project_id}/billing/dashboard -> project.NewGetUsageDashboardHandler
399399
getUsageDashboardEndpoint := factory.NewAPIEndpoint(
400400
&types.APIRequestMetadata{
401401
Verb: types.APIVerbCreate,
@@ -423,6 +423,34 @@ func getProjectRoutes(
423423
Router: r,
424424
})
425425

426+
// POST /api/projects/{project_id}/billing/ingest -> project.NewGetUsageDashboardHandler
427+
ingestEventsEndpoint := factory.NewAPIEndpoint(
428+
&types.APIRequestMetadata{
429+
Verb: types.APIVerbCreate,
430+
Method: types.HTTPVerbPost,
431+
Path: &types.Path{
432+
Parent: basePath,
433+
RelativePath: relPath + "/billing/ingest",
434+
},
435+
Scopes: []types.PermissionScope{
436+
types.UserScope,
437+
types.ProjectScope,
438+
},
439+
},
440+
)
441+
442+
ingestEventsHandler := billing.NewIngestEventsHandler(
443+
config,
444+
factory.GetDecoderValidator(),
445+
factory.GetResultWriter(),
446+
)
447+
448+
routes = append(routes, &router.Route{
449+
Endpoint: ingestEventsEndpoint,
450+
Handler: ingestEventsHandler,
451+
Router: r,
452+
})
453+
426454
// POST /api/projects/{project_id}/billing/payment_method -> project.NewCreateBillingHandler
427455
createBillingEndpoint := factory.NewAPIEndpoint(
428456
&types.APIRequestMetadata{

api/types/billing_metronome.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,12 @@ type ColorOverride struct {
140140
Name string `json:"name"`
141141
Value string `json:"value"`
142142
}
143+
144+
// BillingEvent represents a Metronome billing event.
145+
type BillingEvent struct {
146+
CustomerID string `json:"customer_id"`
147+
EventType string `json:"event_type"`
148+
Properties map[string]interface{} `json:"properties"`
149+
TransactionID string `json:"transaction_id"`
150+
Timestamp string `json:"timestamp"`
151+
}

internal/billing/metronome.go

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,9 @@ import (
1616
)
1717

1818
const (
19-
metronomeBaseUrl = "https://api.metronome.com/v1/"
20-
defaultCollectionMethod = "charge_automatically"
21-
defaultGrantCredits = 5000
22-
defaultGrantName = "Starter Credits"
23-
defaultGrantExpiryMonths = 1
19+
metronomeBaseUrl = "https://api.metronome.com/v1/"
20+
defaultCollectionMethod = "charge_automatically"
21+
defaultMaxRetries = 10
2422
)
2523

2624
// MetronomeClient is the client used to call the Metronome API
@@ -97,7 +95,7 @@ func (m MetronomeClient) createCustomer(ctx context.Context, userEmail string, p
9795
Data types.Customer `json:"data"`
9896
}
9997

100-
err = do(http.MethodPost, path, m.ApiKey, customer, &result)
98+
_, err = m.do(http.MethodPost, path, customer, &result)
10199
if err != nil {
102100
return customerID, telemetry.Error(ctx, span, err, "error creating customer")
103101
}
@@ -131,7 +129,7 @@ func (m MetronomeClient) addCustomerPlan(ctx context.Context, customerID uuid.UU
131129
} `json:"data"`
132130
}
133131

134-
err = do(http.MethodPost, path, m.ApiKey, req, &result)
132+
_, err = m.do(http.MethodPost, path, req, &result)
135133
if err != nil {
136134
return customerPlanID, telemetry.Error(ctx, span, err, "failed to add customer to plan")
137135
}
@@ -154,7 +152,7 @@ func (m MetronomeClient) ListCustomerPlan(ctx context.Context, customerID uuid.U
154152
Data []types.Plan `json:"data"`
155153
}
156154

157-
err = do(http.MethodGet, path, m.ApiKey, nil, &result)
155+
_, err = m.do(http.MethodGet, path, nil, &result)
158156
if err != nil {
159157
return plan, telemetry.Error(ctx, span, err, "failed to list customer plans")
160158
}
@@ -186,7 +184,7 @@ func (m MetronomeClient) EndCustomerPlan(ctx context.Context, customerID uuid.UU
186184
EndingBeforeUTC: endBefore,
187185
}
188186

189-
err = do(http.MethodPost, path, m.ApiKey, req, nil)
187+
_, err = m.do(http.MethodPost, path, req, nil)
190188
if err != nil {
191189
return telemetry.Error(ctx, span, err, "failed to end customer plan")
192190
}
@@ -215,7 +213,7 @@ func (m MetronomeClient) ListCustomerCredits(ctx context.Context, customerID uui
215213
Data []types.CreditGrant `json:"data"`
216214
}
217215

218-
err = do(http.MethodPost, path, m.ApiKey, req, &result)
216+
_, err = m.do(http.MethodPost, path, req, &result)
219217
if err != nil {
220218
return credits, telemetry.Error(ctx, span, err, "failed to list customer credits")
221219
}
@@ -251,61 +249,93 @@ func (m MetronomeClient) GetCustomerDashboard(ctx context.Context, customerID uu
251249
Data map[string]string `json:"data"`
252250
}
253251

254-
err = do(http.MethodPost, path, m.ApiKey, req, &result)
252+
_, err = m.do(http.MethodPost, path, req, &result)
255253
if err != nil {
256254
return url, telemetry.Error(ctx, span, err, "failed to get embeddable dashboard")
257255
}
258256

259257
return result.Data["url"], nil
260258
}
261259

262-
func do(method string, path string, apiKey string, body interface{}, data interface{}) (err error) {
260+
// IngestEvents sends a list of billing events to Metronome's ingest endpoint
261+
func (m MetronomeClient) IngestEvents(ctx context.Context, events []types.BillingEvent) (err error) {
262+
path := "ingest"
263+
264+
var currentAttempts int
265+
for currentAttempts < defaultMaxRetries {
266+
statusCode, err := m.do(http.MethodPost, path, events, nil)
267+
// Check errors that are not from error http codes
268+
if statusCode == 0 && err != nil {
269+
return err
270+
}
271+
272+
if statusCode == http.StatusForbidden || statusCode == http.StatusUnauthorized {
273+
return fmt.Errorf("unauthorized")
274+
}
275+
276+
// 400 responses should not be retried
277+
if statusCode == http.StatusBadRequest {
278+
return fmt.Errorf("malformed billing events")
279+
}
280+
281+
// Any other status code can be safely retried
282+
if statusCode == 200 {
283+
return nil
284+
}
285+
currentAttempts++
286+
}
287+
288+
return fmt.Errorf("max number of retry attempts reached with no success")
289+
}
290+
291+
func (m MetronomeClient) do(method string, path string, body interface{}, data interface{}) (statusCode int, err error) {
263292
client := http.Client{}
264293
endpoint, err := url.JoinPath(metronomeBaseUrl, path)
265294
if err != nil {
266-
return err
295+
return statusCode, err
267296
}
268297

269298
var bodyJson []byte
270299
if body != nil {
271300
bodyJson, err = json.Marshal(body)
272301
if err != nil {
273-
return err
302+
return statusCode, err
274303
}
275304
}
276305

277306
req, err := http.NewRequest(method, endpoint, bytes.NewBuffer(bodyJson))
278307
if err != nil {
279-
return err
308+
return statusCode, err
280309
}
281-
bearer := "Bearer " + apiKey
310+
bearer := "Bearer " + m.ApiKey
282311
req.Header.Set("Authorization", bearer)
283312
req.Header.Set("Content-Type", "application/json")
284313

285314
resp, err := client.Do(req)
286315
if err != nil {
287-
return err
316+
return statusCode, err
288317
}
318+
statusCode = resp.StatusCode
289319

290320
if resp.StatusCode != http.StatusOK {
291321
// If there is an error, try to decode the message
292322
var message map[string]string
293323
err = json.NewDecoder(resp.Body).Decode(&message)
294324
if err != nil {
295-
return fmt.Errorf("status code %d received, couldn't process response message", resp.StatusCode)
325+
return statusCode, fmt.Errorf("status code %d received, couldn't process response message", resp.StatusCode)
296326
}
297327
_ = resp.Body.Close()
298328

299-
return fmt.Errorf("status code %d received, response message: %v", resp.StatusCode, message)
329+
return statusCode, fmt.Errorf("status code %d received, response message: %v", resp.StatusCode, message)
300330
}
301331

302332
if data != nil {
303333
err = json.NewDecoder(resp.Body).Decode(data)
304334
if err != nil {
305-
return err
335+
return statusCode, err
306336
}
307337
}
308338
_ = resp.Body.Close()
309339

310-
return nil
340+
return statusCode, nil
311341
}

0 commit comments

Comments
 (0)