Skip to content

Commit

Permalink
DE-1315 Add support for metrics API (#328)
Browse files Browse the repository at this point in the history
  • Loading branch information
vtopc authored Oct 25, 2024
1 parent fa30808 commit 1733cdb
Show file tree
Hide file tree
Showing 20 changed files with 486 additions and 59 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.DS_Store
.idea/
cmd/mailgun/mailgun
/.env
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ $(NILAWAY):
go install go.uber.org/nilaway/cmd/nilaway@latest

.PHONY: all
all:
all: test

.PHONY: test
test:
export GO111MODULE=on; go test . -v

.PHONY: godoc
Expand Down
110 changes: 110 additions & 0 deletions analytics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package mailgun

import (
"context"
"strings"

"github.com/mailgun/errors"
)

type MetricsPagination struct {
// Colon-separated value indicating column name and sort direction e.g. 'domain:asc'.
Sort string `json:"sort"`
// The number of items to skip over when satisfying the request. To get the first page of data set skip to zero. Then increment the skip by the limit for subsequent calls.
Skip int `json:"skip"`
// The maximum number of items returned in the response.
Limit int `json:"limit"`
// The total number of items in the query result set.
Total int `json:"total"`
}

// ListMetrics returns domain/account metrics.
//
// NOTE: Only for v1 API. To use the /v1 version define MG_URL in the environment variable
// as `https://api.mailgun.net/v1` or set `mg.SetAPIBase("https://api.mailgun.net/v1")`
//
// https://documentation.mailgun.com/docs/mailgun/api-reference/openapi-final/tag/Metrics/
func (mg *MailgunImpl) ListMetrics(opts MetricsOptions) (*MetricsIterator, error) {
if !strings.HasSuffix(mg.APIBase(), "/v1") {
return nil, errors.New("only v1 API is supported")
}

domain := mg.Domain()
if domain != "" {
domainFilter := MetricsFilterPredicate{
Attribute: "domain",
Comparator: "=",
LabeledValues: []MetricsLabeledValue{{Label: domain, Value: domain}},
}

opts.Filter.BoolGroupAnd = append(opts.Filter.BoolGroupAnd, domainFilter)
}

if opts.Pagination.Limit == 0 {
opts.Pagination.Limit = 10
}

req := newHTTPRequest(generatePublicApiUrl(mg, metricsEndpoint))
req.setClient(mg.Client())
req.setBasicAuth(basicAuthUser, mg.APIKey())

return &MetricsIterator{
opts: opts,
req: req,
}, nil
}

type MetricsIterator struct {
opts MetricsOptions
req *httpRequest
err error
}

func (iter *MetricsIterator) Err() error {
return iter.err
}

// Next retrieves the next page of items from the api. Returns false when there are
// no more pages to retrieve or if there was an error.
// Use `.Err()` to retrieve the error
func (iter *MetricsIterator) Next(ctx context.Context, resp *MetricsResponse) (more bool) {
if iter.err != nil {
return false
}

iter.err = iter.fetch(ctx, resp)
if iter.err != nil {
return false
}

iter.opts.Pagination.Skip = iter.opts.Pagination.Skip + iter.opts.Pagination.Limit

if len(resp.Items) < iter.opts.Pagination.Limit {
return false
}

return true
}

func (iter *MetricsIterator) fetch(ctx context.Context, resp *MetricsResponse) error {
if resp == nil {
return errors.New("resp cannot be nil")
}

payload := newJSONEncodedPayload(iter.opts)

httpResp, err := makePostRequest(ctx, iter.req, payload)
if err != nil {
return err
}

// preallocate
resp.Items = make([]MetricsItem, 0, iter.opts.Pagination.Limit)

err = httpResp.parseFromJSON(resp)
if err != nil {
return errors.Wrap(err, "decoding response")
}

return nil
}
40 changes: 40 additions & 0 deletions analytics_request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package mailgun

type MetricsOptions struct {
// A start date (default: 7 days before current time).
Start RFC2822Time `json:"start,omitempty"`
// An end date (default: current time).
End RFC2822Time `json:"end,omitempty"`
// A resolution in the format of 'day' 'hour' 'month'. Default is day.
Resolution Resolution `json:"resolution,omitempty"`
// A duration in the format of '1d' '2h' '2m'.
// If duration is provided then it is calculated from the end date and overwrites the start date.
Duration string `json:"duration,omitempty"`
// Attributes of the metric data such as 'time' 'domain' 'ip' 'ip_pool' 'recipient_domain' 'tag' 'country' 'subaccount'.
Dimensions []string `json:"dimensions,omitempty"`
// Name of the metrics to receive the stats for such as 'accepted_count' 'delivered_count' 'accepted_rate'.
Metrics []string `json:"metrics,omitempty"`
// Filters to apply to the query.
Filter MetricsFilterPredicateGroup `json:"filter,omitempty"`
// Include stats from all subaccounts.
IncludeSubaccounts bool `json:"include_subaccounts,omitempty"`
// Include top-level aggregate metrics.
IncludeAggregates bool `json:"include_aggregates,omitempty"`
// Attributes used for pagination and sorting.
Pagination MetricsPagination `json:"pagination,omitempty"`
}

type MetricsLabeledValue struct {
Label string `json:"label"`
Value string `json:"value"`
}

type MetricsFilterPredicate struct {
Attribute string `json:"attribute"`
Comparator string `json:"comparator"`
LabeledValues []MetricsLabeledValue `json:"values,omitempty"`
}

type MetricsFilterPredicateGroup struct {
BoolGroupAnd []MetricsFilterPredicate `json:"AND,omitempty"`
}
95 changes: 95 additions & 0 deletions analytics_response.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package mailgun

type MetricsResponse struct {
Start RFC2822Time `json:"start"`
End RFC2822Time `json:"end"`
Resolution Resolution `json:"resolution"`
Duration string `json:"duration"`
Dimensions []string `json:"dimensions"`
Aggregates MetricsAggregates `json:"aggregates"`
Items []MetricsItem `json:"items"`
Pagination MetricsPagination `json:"pagination"`
}

type MetricsItem struct {
Dimensions []MetricsDimension `json:"dimensions"`
Metrics Metrics `json:"metrics"`
}

type MetricsAggregates struct {
Metrics Metrics `json:"metrics"`
}

type Metrics struct {
AcceptedIncomingCount *uint64 `json:"accepted_incoming_count,omitempty"`
AcceptedOutgoingCount *uint64 `json:"accepted_outgoing_count,omitempty"`
AcceptedCount *uint64 `json:"accepted_count,omitempty"`
DeliveredSMTPCount *uint64 `json:"delivered_smtp_count,omitempty"`
DeliveredHTTPCount *uint64 `json:"delivered_http_count,omitempty"`
DeliveredOptimizedCount *uint64 `json:"delivered_optimized_count,omitempty"`
DeliveredCount *uint64 `json:"delivered_count,omitempty"`
StoredCount *uint64 `json:"stored_count,omitempty"`
ProcessedCount *uint64 `json:"processed_count,omitempty"`
SentCount *uint64 `json:"sent_count,omitempty"`
OpenedCount *uint64 `json:"opened_count,omitempty"`
ClickedCount *uint64 `json:"clicked_count,omitempty"`
UniqueOpenedCount *uint64 `json:"unique_opened_count,omitempty"`
UniqueClickedCount *uint64 `json:"unique_clicked_count,omitempty"`
UnsubscribedCount *uint64 `json:"unsubscribed_count,omitempty"`
ComplainedCount *uint64 `json:"complained_count,omitempty"`
FailedCount *uint64 `json:"failed_count,omitempty"`
TemporaryFailedCount *uint64 `json:"temporary_failed_count,omitempty"`
PermanentFailedCount *uint64 `json:"permanent_failed_count,omitempty"`
ESPBlockCount *uint64 `json:"esp_block_count,omitempty"`
WebhookCount *uint64 `json:"webhook_count,omitempty"`
PermanentFailedOptimizedCount *uint64 `json:"permanent_failed_optimized_count,omitempty"`
PermanentFailedOldCount *uint64 `json:"permanent_failed_old_count,omitempty"`
BouncedCount *uint64 `json:"bounced_count,omitempty"`
HardBouncesCount *uint64 `json:"hard_bounces_count,omitempty"`
SoftBouncesCount *uint64 `json:"soft_bounces_count,omitempty"`
DelayedBounceCount *uint64 `json:"delayed_bounce_count,omitempty"`
SuppressedBouncesCount *uint64 `json:"suppressed_bounces_count,omitempty"`
SuppressedUnsubscribedCount *uint64 `json:"suppressed_unsubscribed_count,omitempty"`
SuppressedComplaintsCount *uint64 `json:"suppressed_complaints_count,omitempty"`
DeliveredFirstAttemptCount *uint64 `json:"delivered_first_attempt_count,omitempty"`
DelayedFirstAttemptCount *uint64 `json:"delayed_first_attempt_count,omitempty"`
DeliveredSubsequentCount *uint64 `json:"delivered_subsequent_count,omitempty"`
DeliveredTwoPlusAttemptsCount *uint64 `json:"delivered_two_plus_attempts_count,omitempty"`

DeliveredRate string `json:"delivered_rate,omitempty"`
OpenedRate string `json:"opened_rate,omitempty"`
ClickedRate string `json:"clicked_rate,omitempty"`
UniqueOpenedRate string `json:"unique_opened_rate,omitempty"`
UniqueClickedRate string `json:"unique_clicked_rate,omitempty"`
UnsubscribedRate string `json:"unsubscribed_rate,omitempty"`
ComplainedRate string `json:"complained_rate,omitempty"`
BounceRate string `json:"bounce_rate,omitempty"`
FailRate string `json:"fail_rate,omitempty"`
PermanentFailRate string `json:"permanent_fail_rate,omitempty"`
TemporaryFailRate string `json:"temporary_fail_rate,omitempty"`
DelayedRate string `json:"delayed_rate,omitempty"`

// usage metrics
EmailValidationCount *uint64 `json:"email_validation_count,omitempty"`
EmailValidationPublicCount *uint64 `json:"email_validation_public_count,omitempty"`
EmailValidationValidCount *uint64 `json:"email_validation_valid_count,omitempty"`
EmailValidationSingleCount *uint64 `json:"email_validation_single_count,omitempty"`
EmailValidationBulkCount *uint64 `json:"email_validation_bulk_count,omitempty"`
EmailValidationListCount *uint64 `json:"email_validation_list_count,omitempty"`
EmailValidationMailgunCount *uint64 `json:"email_validation_mailgun_count,omitempty"`
EmailValidationMailjetCount *uint64 `json:"email_validation_mailjet_count,omitempty"`
EmailPreviewCount *uint64 `json:"email_preview_count,omitempty"`
EmailPreviewFailedCount *uint64 `json:"email_preview_failed_count,omitempty"`
LinkValidationCount *uint64 `json:"link_validation_count,omitempty"`
LinkValidationFailedCount *uint64 `json:"link_validation_failed_count,omitempty"`
SeedTestCount *uint64 `json:"seed_test_count,omitempty"`
}

type MetricsDimension struct {
// The dimension
Dimension string `json:"dimension"`
// The dimension value
Value string `json:"value"`
// The dimension value in displayable form
DisplayValue string `json:"display_value"`
}
65 changes: 65 additions & 0 deletions analytics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package mailgun_test

import (
"context"
"testing"

"github.com/mailgun/mailgun-go/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestListMetrics(t *testing.T) {
mg := mailgun.NewMailgun(testDomain, testKey)
mg.SetAPIBase(server.URL1())

start, _ := mailgun.NewRFC2822Time("Tue, 24 Sep 2024 00:00:00 +0000")
end, _ := mailgun.NewRFC2822Time("Tue, 24 Oct 2024 00:00:00 +0000")

opts := mailgun.MetricsOptions{
Start: start,
End: end,
Pagination: mailgun.MetricsPagination{
Limit: 10,
},
}

wantResp := mailgun.MetricsResponse{
Start: start,
End: end,
Resolution: "day",
Duration: "30d",
Dimensions: []string{"time"},
Items: []mailgun.MetricsItem{
{
Dimensions: []mailgun.MetricsDimension{{
Dimension: "time",
Value: "Tue, 24 Sep 2024 00:00:00 +0000",
DisplayValue: "Tue, 24 Sep 2024 00:00:00 +0000",
}},
Metrics: mailgun.Metrics{
SentCount: ptr(uint64(4)),
DeliveredCount: ptr(uint64(3)),
OpenedCount: ptr(uint64(2)),
FailedCount: ptr(uint64(1)),
},
},
},
Pagination: mailgun.MetricsPagination{
Sort: "",
Skip: 0,
Limit: 10,
Total: 1,
},
}

it, err := mg.ListMetrics(opts)
require.NoError(t, err)

var page mailgun.MetricsResponse
ctx := context.Background()
more := it.Next(ctx, &page)
require.Nil(t, it.Err())
assert.False(t, more)
assert.Equal(t, wantResp, page)
}
4 changes: 2 additions & 2 deletions bounces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ func TestAddDelBounceList(t *testing.T) {
return false
}

createdAt, err := mailgun.NewRFC2822Time("Thu, 13 Oct 2011 18:02:00 UTC")
createdAt, err := mailgun.NewRFC2822Time("Thu, 13 Oct 2011 18:02:00 +0000")
if err != nil {
t.Fatalf("invalid time")
}
Expand Down Expand Up @@ -162,7 +162,7 @@ func TestAddDelBounceList(t *testing.T) {
t.Fatalf("Expected at least one bounce for %s", expect.Address)
}
t.Logf("Bounce Created At: %s", bounce.CreatedAt)
if !expect.CreatedAt.IsZero() && bounce.CreatedAt != expect.CreatedAt {
if !expect.CreatedAt.IsZero() && !time.Time(bounce.CreatedAt).Equal(time.Time(expect.CreatedAt)) {
t.Fatalf("Expected bounce createdAt to be %s, got %s", expect.CreatedAt, bounce.CreatedAt)
}
}
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/go-chi/chi/v5 v5.0.8
github.com/json-iterator/go v1.1.10
github.com/mailgun/errors v0.3.0
github.com/stretchr/testify v1.9.0
)

require (
Expand All @@ -15,6 +16,8 @@ require (
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sirupsen/logrus v1.9.0 // indirect
golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
5 changes: 3 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@ github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 h1:h+EGohizhe9XlX18rfpa8k8RAc5XyaeamM+0VHRd4lc=
golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down
4 changes: 2 additions & 2 deletions httphelpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
"github.com/mailgun/errors"
)

var validURL = regexp.MustCompile(`/v[2-5].*`)
var validURL = regexp.MustCompile(`/v[1-5].*`)

type httpRequest struct {
URL string
Expand Down Expand Up @@ -332,7 +332,7 @@ func (r *httpRequest) generateUrlWithParameters() (string, error) {
}

if !validURL.MatchString(url.Path) {
return "", errors.New(`BaseAPI must end with a /v2, /v3 or /v4; setBaseAPI("https://host/v3")`)
return "", errors.New(`BaseAPI must end with a /v1, /v2, /v3 or /v4; setBaseAPI("https://host/v3")`)
}

q := url.Query()
Expand Down
Loading

0 comments on commit 1733cdb

Please sign in to comment.