Skip to content

Commit ffa7aaf

Browse files
authored
App Store Connect API telemetry (#273)
* App Store Connect API telemetry * Fix lint * Temp stdout tracker in integration tests * Fix test * temp: use stdout tracker for testing * Cleanup * Add step_execution_id field to events
1 parent 1787471 commit ffa7aaf

File tree

9 files changed

+275
-7
lines changed

9 files changed

+275
-7
lines changed

_integration_tests/appstoreconnect_tests/appstoreconnect_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
func TestListBundleIDs(t *testing.T) {
1111
keyID, issuerID, privateKey, enterpriseAccount := getAPIKey(t)
1212

13-
client := appstoreconnect.NewClient(appstoreconnect.NewRetryableHTTPClient(), keyID, issuerID, []byte(privateKey), enterpriseAccount)
13+
client := appstoreconnect.NewClient(appstoreconnect.NewRetryableHTTPClient(), keyID, issuerID, []byte(privateKey), enterpriseAccount, appstoreconnect.NoOpAnalyticsTracker{})
1414

1515
response, err := client.Provisioning.ListBundleIDs(&appstoreconnect.ListBundleIDsOptions{})
1616
require.NoError(t, err)

autocodesign/devportalclient/appstoreconnect/appstoreconnect.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ type Client struct {
6868

6969
common service // Reuse a single struct instead of allocating one for each service on the heap.
7070
Provisioning *ProvisioningService
71+
72+
tracker Tracker
7173
}
7274

7375
// NewRetryableHTTPClient create a new http client with retry settings.
@@ -117,7 +119,7 @@ func NewRetryableHTTPClient() *http.Client {
117119
}
118120

119121
// NewClient creates a new client
120-
func NewClient(httpClient HTTPClient, keyID, issuerID string, privateKey []byte, isEnterpise bool) *Client {
122+
func NewClient(httpClient HTTPClient, keyID, issuerID string, privateKey []byte, isEnterpise bool, tracker Tracker) *Client {
121123
targetURL := clientBaseURL
122124
targetAudience := tokenAudience
123125
if isEnterpise {
@@ -138,6 +140,7 @@ func NewClient(httpClient HTTPClient, keyID, issuerID string, privateKey []byte,
138140

139141
client: httpClient,
140142
BaseURL: baseURL,
143+
tracker: tracker,
141144
}
142145
c.common.client = c
143146
c.Provisioning = (*ProvisioningService)(&c.common)
@@ -162,6 +165,7 @@ func (c *Client) ensureSignedToken() (string, error) {
162165
c.token = createToken(c.keyID, c.issuerID, c.audience)
163166
var err error
164167
if c.signedToken, err = signToken(c.token, c.privateKeyContent); err != nil {
168+
c.tracker.TrackAuthError(fmt.Sprintf("JWT signing: %s", err.Error()))
165169
return "", err
166170
}
167171
return c.signedToken, nil
@@ -241,6 +245,8 @@ func (c *Client) Debugf(format string, v ...interface{}) {
241245

242246
// Do ...
243247
func (c *Client) Do(req *http.Request, v interface{}) (*http.Response, error) {
248+
startTime := time.Now()
249+
244250
c.Debugf("Request:")
245251
if c.EnableDebugLogs {
246252
if err := httputil.PrintRequest(req); err != nil {
@@ -249,6 +255,7 @@ func (c *Client) Do(req *http.Request, v interface{}) (*http.Response, error) {
249255
}
250256

251257
resp, err := c.client.Do(req)
258+
duration := time.Since(startTime)
252259

253260
c.Debugf("Response:")
254261
if c.EnableDebugLogs {
@@ -258,18 +265,24 @@ func (c *Client) Do(req *http.Request, v interface{}) (*http.Response, error) {
258265
}
259266

260267
if err != nil {
268+
c.tracker.TrackAPIError(req.Method, req.URL.Host, req.URL.Path, 0, err.Error())
261269
return nil, err
262270
}
271+
263272
defer func() {
264273
if cerr := resp.Body.Close(); cerr != nil {
265274
log.Warnf("Failed to close response body: %s", cerr)
266275
}
267276
}()
268277

269278
if err := checkResponse(resp); err != nil {
279+
c.tracker.TrackAPIError(req.Method, req.URL.Host, req.URL.Path, resp.StatusCode, err.Error())
270280
return resp, err
271281
}
272282

283+
c.tracker.TrackAPIRequest(req.Method, req.URL.Host, req.URL.Path, resp.StatusCode, duration)
284+
285+
273286
if v != nil {
274287
decErr := json.NewDecoder(resp.Body).Decode(v)
275288
if decErr == io.EOF {

autocodesign/devportalclient/appstoreconnect/appstoreconnect_test.go

Lines changed: 159 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@
44
package appstoreconnect
55

66
import (
7+
"errors"
8+
"io"
9+
"net/http"
10+
"net/http/httptest"
711
"net/url"
12+
"strings"
813
"testing"
14+
"time"
915

1016
"github.com/stretchr/testify/require"
1117
)
1218

1319
func TestNewClient(t *testing.T) {
14-
got := NewClient(NewRetryableHTTPClient(), "keyID", "issuerID", []byte{}, false)
20+
got := NewClient(NewRetryableHTTPClient(), "keyID", "issuerID", []byte{}, false, NoOpAnalyticsTracker{})
1521

1622
require.Equal(t, "appstoreconnect-v1", got.audience)
1723

@@ -21,11 +27,162 @@ func TestNewClient(t *testing.T) {
2127
}
2228

2329
func TestNewEnterpriseClient(t *testing.T) {
24-
got := NewClient(NewRetryableHTTPClient(), "keyID", "issuerID", []byte{}, true)
30+
got := NewClient(NewRetryableHTTPClient(), "keyID", "issuerID", []byte{}, true, NoOpAnalyticsTracker{})
2531

2632
require.Equal(t, "apple-developer-enterprise-v1", got.audience)
2733

2834
wantURL, err := url.Parse("https://api.enterprise.developer.apple.com/")
2935
require.NoError(t, err)
3036
require.Equal(t, wantURL, got.BaseURL)
3137
}
38+
39+
type mockAnalyticsTracker struct {
40+
apiRequests []apiRequestRecord
41+
apiErrors []apiErrorRecord
42+
authErrors []string
43+
}
44+
45+
type apiRequestRecord struct {
46+
method string
47+
host string
48+
endpoint string
49+
statusCode int
50+
duration time.Duration
51+
}
52+
53+
type apiErrorRecord struct {
54+
method string
55+
host string
56+
endpoint string
57+
statusCode int
58+
errorMessage string
59+
}
60+
61+
func (m *mockAnalyticsTracker) TrackAPIRequest(method, host, endpoint string, statusCode int, duration time.Duration) {
62+
m.apiRequests = append(m.apiRequests, apiRequestRecord{
63+
method: method,
64+
host: host,
65+
endpoint: endpoint,
66+
statusCode: statusCode,
67+
duration: duration,
68+
})
69+
}
70+
71+
func (m *mockAnalyticsTracker) TrackAPIError(method, host, endpoint string, statusCode int, errorMessage string) {
72+
m.apiErrors = append(m.apiErrors, apiErrorRecord{
73+
method: method,
74+
host: host,
75+
endpoint: endpoint,
76+
statusCode: statusCode,
77+
errorMessage: errorMessage,
78+
})
79+
}
80+
81+
func (m *mockAnalyticsTracker) TrackAuthError(errorMessage string) {
82+
m.authErrors = append(m.authErrors, errorMessage)
83+
}
84+
85+
type mockHTTPClient struct {
86+
resp *http.Response
87+
err error
88+
called bool
89+
}
90+
91+
func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
92+
m.called = true
93+
return m.resp, m.err
94+
}
95+
96+
func TestTracking(t *testing.T) {
97+
t.Run("successful request", func(t *testing.T) {
98+
mockTracker := &mockAnalyticsTracker{}
99+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100+
w.WriteHeader(http.StatusOK)
101+
_, err := w.Write([]byte(`{"data": []}`))
102+
require.NoError(t, err)
103+
}))
104+
defer server.Close()
105+
106+
client := &Client{
107+
client: &http.Client{},
108+
tracker: mockTracker,
109+
}
110+
111+
req, err := http.NewRequest("GET", server.URL+"/test", nil)
112+
require.NoError(t, err)
113+
_, err = client.Do(req, nil)
114+
require.NoError(t, err)
115+
116+
if len(mockTracker.apiRequests) != 1 {
117+
t.Errorf("Expected 1 API request tracked, got %d", len(mockTracker.apiRequests))
118+
}
119+
120+
if len(mockTracker.apiErrors) != 0 {
121+
t.Errorf("Expected 0 API errors tracked, got %d", len(mockTracker.apiErrors))
122+
}
123+
124+
record := mockTracker.apiRequests[0]
125+
if record.method != "GET" {
126+
t.Errorf("Expected method GET, got %s", record.method)
127+
}
128+
if record.statusCode != 200 {
129+
t.Errorf("Expected status code 200, got %d", record.statusCode)
130+
}
131+
})
132+
133+
t.Run("error response", func(t *testing.T) {
134+
mockTracker := &mockAnalyticsTracker{}
135+
mockHTTPClient := &mockHTTPClient{
136+
resp: &http.Response{
137+
StatusCode: 400,
138+
Body: io.NopCloser(strings.NewReader(`{"errors": [{"code": "PARAMETER_ERROR.INVALID", "title": "Invalid parameter"}]}`)),
139+
Header: http.Header{},
140+
},
141+
}
142+
143+
client := &Client{
144+
client: mockHTTPClient,
145+
tracker: mockTracker,
146+
}
147+
148+
req, err := http.NewRequest("POST", "https://example.com/test", nil)
149+
require.NoError(t, err)
150+
_, err = client.Do(req, nil)
151+
require.Error(t, err, "Expected error due to 400 Bad Request response")
152+
153+
require.True(t, mockHTTPClient.called, "Expected HTTP client to be called")
154+
155+
require.Len(t, mockTracker.apiRequests, 0, "Expected 0 (successful) API requests tracked")
156+
require.Len(t, mockTracker.apiErrors, 1, "Expected 1 API error tracked")
157+
158+
errorRecord := mockTracker.apiErrors[0]
159+
require.Equal(t, "POST", errorRecord.method)
160+
require.Equal(t, 400, errorRecord.statusCode)
161+
})
162+
163+
t.Run("network error", func(t *testing.T) {
164+
mockTracker := &mockAnalyticsTracker{}
165+
166+
mockHTTPClient := &mockHTTPClient{
167+
err: errors.New("network connection failed"),
168+
}
169+
170+
client := &Client{
171+
client: mockHTTPClient,
172+
tracker: mockTracker,
173+
}
174+
175+
req, err := http.NewRequest("GET", "https://api.appstoreconnect.apple.com/test", nil)
176+
require.NoError(t, err)
177+
_, err = client.Do(req, nil)
178+
require.Error(t, err)
179+
180+
require.Len(t, mockTracker.apiRequests, 0, "Expected 0 API requests tracked")
181+
require.Len(t, mockTracker.apiErrors, 1, "Expected 1 API error tracked")
182+
183+
record := mockTracker.apiErrors[0]
184+
require.Equal(t, "GET", record.method)
185+
require.Equal(t, 0, record.statusCode)
186+
require.Equal(t, "network connection failed", record.errorMessage)
187+
})
188+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package appstoreconnect
2+
3+
import (
4+
"time"
5+
6+
"github.com/bitrise-io/go-utils/v2/analytics"
7+
"github.com/bitrise-io/go-utils/v2/env"
8+
)
9+
10+
// Tracker defines the interface for tracking App Store Connect API usage and errors.
11+
type Tracker interface {
12+
// TrackAPIRequest tracks one completed API request (even if it failed)
13+
TrackAPIRequest(method, host, endpoint string, statusCode int, duration time.Duration)
14+
15+
// TrackAPIError tracks a failed API request with error details
16+
TrackAPIError(method, host, endpoint string, statusCode int, errorMessage string)
17+
18+
// TrackAuthError tracks authentication-specific errors
19+
TrackAuthError(errorMessage string)
20+
}
21+
22+
// NoOpAnalyticsTracker is a dummy implementation used in tests.
23+
type NoOpAnalyticsTracker struct{}
24+
25+
// TrackAPIRequest ...
26+
func (n NoOpAnalyticsTracker) TrackAPIRequest(method, host, endpoint string, statusCode int, duration time.Duration) {
27+
}
28+
29+
// TrackAPIError ...
30+
func (n NoOpAnalyticsTracker) TrackAPIError(method, host, endpoint string, statusCode int, errorMessage string) {
31+
}
32+
33+
// TrackAuthError ...
34+
func (n NoOpAnalyticsTracker) TrackAuthError(errorMessage string) {}
35+
36+
// DefaultTracker is the main implementation of Tracker
37+
type DefaultTracker struct {
38+
tracker analytics.Tracker
39+
envRepo env.Repository
40+
}
41+
42+
// NewDefaultTracker ...
43+
func NewDefaultTracker(tracker analytics.Tracker, envRepo env.Repository) *DefaultTracker {
44+
return &DefaultTracker{
45+
tracker: tracker,
46+
envRepo: envRepo,
47+
}
48+
}
49+
50+
// TrackAPIRequest ...
51+
func (d *DefaultTracker) TrackAPIRequest(method, host, endpoint string, statusCode int, duration time.Duration) {
52+
d.tracker.Enqueue("step_appstoreconnect_request", analytics.Properties{
53+
"build_slug": d.envRepo.Get("BITRISE_BUILD_SLUG"),
54+
"step_execution_id": d.envRepo.Get("BITRISE_STEP_EXECUTION_ID"),
55+
"http_method": method,
56+
"host": host, // Regular, enterprise, or any future third option
57+
"endpoint": endpoint,
58+
"status_code": statusCode,
59+
"duration_ms": duration.Truncate(time.Millisecond).Milliseconds(),
60+
})
61+
}
62+
63+
// TrackAPIError ...
64+
func (d *DefaultTracker) TrackAPIError(method, host, endpoint string, statusCode int, errorMessage string) {
65+
d.tracker.Enqueue("step_appstoreconnect_error", analytics.Properties{
66+
"build_slug": d.envRepo.Get("BITRISE_BUILD_SLUG"),
67+
"step_execution_id": d.envRepo.Get("BITRISE_STEP_EXECUTION_ID"),
68+
"http_method": method,
69+
"host": host, // Regular, enterprise, or any future third option
70+
"endpoint": endpoint,
71+
"status_code": statusCode,
72+
"error_message": errorMessage,
73+
})
74+
}
75+
76+
// TrackAuthError ...
77+
func (d *DefaultTracker) TrackAuthError(errorMessage string) {
78+
d.tracker.Enqueue("step_appstoreconnect_auth_error", analytics.Properties{
79+
"build_slug": d.envRepo.Get("BITRISE_BUILD_SLUG"),
80+
"step_execution_id": d.envRepo.Get("BITRISE_STEP_EXECUTION_ID"),
81+
"error_message": errorMessage,
82+
})
83+
}

autocodesign/devportalclient/appstoreconnectclient/devices_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func TestDeviceClient_RegisterDevice_WhenInvaludUUID(t *testing.T) {
3939
},
4040
})
4141

42-
client := appstoreconnect.NewClient(&mockClient, "keyID", "issueID", []byte("privateKey"), false)
42+
client := appstoreconnect.NewClient(&mockClient, "keyID", "issueID", []byte("privateKey"), false, appstoreconnect.NoOpAnalyticsTracker{})
4343
deviceClient := NewDeviceClient(client)
4444

4545
got, err := deviceClient.RegisterDevice(devportalservice.TestDevice{

autocodesign/devportalclient/appstoreconnectclient/profiles_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func TestEnsureProfile_ExpiredProfile(t *testing.T) {
8888
On("PostProfilesSuccess", mock.AnythingOfType("*http.Request")).
8989
Return(newResponse(t, http.StatusOK, map[string]interface{}{}), nil)
9090

91-
client := appstoreconnect.NewClient(mockClient, "keyID", "issueID", []byte("privateKey"), false)
91+
client := appstoreconnect.NewClient(mockClient, "keyID", "issueID", []byte("privateKey"), false, appstoreconnect.NoOpAnalyticsTracker{})
9292
profileClient := NewProfileClient(client)
9393
bundleID := appstoreconnect.BundleID{
9494
Attributes: appstoreconnect.BundleIDAttributes{Identifier: "io.bitrise.testapp"},

autocodesign/devportalclient/devportalclient.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/bitrise-io/go-steputils/v2/ruby"
99
"github.com/bitrise-io/go-utils/retry"
10+
"github.com/bitrise-io/go-utils/v2/analytics"
1011
"github.com/bitrise-io/go-utils/v2/command"
1112
"github.com/bitrise-io/go-utils/v2/env"
1213
"github.com/bitrise-io/go-utils/v2/fileutil"
@@ -74,9 +75,17 @@ func (f Factory) Create(credentials devportalservice.Credentials, teamID string)
7475
f.logger.Println()
7576
f.logger.Infof("Initializing Developer Portal client")
7677
var devportalClient autocodesign.DevPortalClient
78+
tracker := appstoreconnect.NewDefaultTracker(analytics.NewDefaultTracker(f.logger), env.NewRepository())
7779
if credentials.APIKey != nil {
7880
httpClient := appstoreconnect.NewRetryableHTTPClient()
79-
client := appstoreconnect.NewClient(httpClient, credentials.APIKey.KeyID, credentials.APIKey.IssuerID, []byte(credentials.APIKey.PrivateKey), credentials.APIKey.EnterpriseAccount)
81+
client := appstoreconnect.NewClient(
82+
httpClient,
83+
credentials.APIKey.KeyID,
84+
credentials.APIKey.IssuerID,
85+
[]byte(credentials.APIKey.PrivateKey),
86+
credentials.APIKey.EnterpriseAccount,
87+
tracker,
88+
)
8089
client.EnableDebugLogs = false // Turn off client debug logs including HTTP call debug logs
8190
devportalClient = appstoreconnectclient.NewAPIDevPortalClient(client)
8291
f.logger.Debugf("App Store Connect API client created with base URL: %s", client.BaseURL)

0 commit comments

Comments
 (0)