@pbvision/cloud-run-service 0.0.49 → 0.0.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pbvision/cloud-run-service",
3
- "version": "0.0.49",
3
+ "version": "0.0.53",
4
4
  "description": "fastify-firestore-service Web Framework on Cloud Run",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -28,7 +28,8 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@google-cloud/tasks": "^4.0.1",
31
- "@pbvision/fastify-firestore-service": "^0.0.53",
31
+ "@pbvision/fastify-firestore-service": "^0.0.55",
32
+ "@sentry/node": "^7.91.0",
32
33
  "google-auth-library": "^9.4.2",
33
34
  "ua-parser-js": "^1.0.37"
34
35
  },
package/src/analytics.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import assert from 'node:assert'
2
2
  import crypto, { randomUUID } from 'node:crypto'
3
3
 
4
- import { DatabaseAPI, EXCEPTIONS } from '@pbvision/fastify-firestore-service'
4
+ import { DatabaseAPI } from '@pbvision/fastify-firestore-service'
5
+ import * as Sentry from '@sentry/node'
5
6
  import UAParser from 'ua-parser-js'
6
7
 
7
8
  import { isProd } from './utils.js'
@@ -9,11 +10,53 @@ import { isProd } from './utils.js'
9
10
  // istanbul ignore next
10
11
  export const mixpanelToken = isProd ? '78c48e38f59ab21c1850740e2bb4ecff' : '52bd993b07bdba759c2f141345e7c32a'
11
12
 
13
+ const mixpanelTrackURL = 'https://api.mixpanel.com/track'
12
14
  const mixpanelUpdateProfileURLs = {
13
15
  $set: 'https://api.mixpanel.com/engage#profile-set',
14
16
  $set_once: 'https://api.mixpanel.com/engage#profile-set-once'
15
17
  }
16
18
 
19
+ // How long to wait before re-sending a Mixpanel request that failed in a way a
20
+ // retry could fix. Short and few on purpose: this runs inside a live request,
21
+ // so the caller waits out every retry.
22
+ const MIXPANEL_RETRY_DELAYS_MS = [100, 300]
23
+
24
+ // At most one Sentry report per failure kind per window, per process. A
25
+ // Mixpanel outage affects every request we serve, so reporting each one would
26
+ // exhaust the error budget in minutes.
27
+ const ANALYTICS_FAILURE_REPORT_WINDOW_MS = 5 * 60 * 1000
28
+
29
+ const analyticsFailureLastReportedAtMs = new Map()
30
+
31
+ /**
32
+ * Whether a Mixpanel failure should be reported to Sentry now, or suppressed
33
+ * because an identical one was reported recently. Recording and deciding are
34
+ * one step: a suppressed failure does not extend the window.
35
+ *
36
+ * @param {String} key identifies the kind of failure; failures sharing a key
37
+ * are suppressed as duplicates of each other
38
+ * @param {Number} [nowMs=Date.now()] current time (injectable for tests)
39
+ * @returns {Boolean} true if this failure should be sent to Sentry
40
+ */
41
+ export function shouldReportAnalyticsFailure (key, nowMs = Date.now()) {
42
+ const lastReportedAtMs = analyticsFailureLastReportedAtMs.get(key)
43
+ if (lastReportedAtMs !== undefined &&
44
+ nowMs - lastReportedAtMs < ANALYTICS_FAILURE_REPORT_WINDOW_MS) {
45
+ return false
46
+ }
47
+ analyticsFailureLastReportedAtMs.set(key, nowMs)
48
+ return true
49
+ }
50
+
51
+ // Exported for tests: the suppression window outlives a single test case.
52
+ export function resetAnalyticsFailureReporting () {
53
+ analyticsFailureLastReportedAtMs.clear()
54
+ }
55
+
56
+ function sleep (ms) {
57
+ return new Promise(resolve => setTimeout(resolve, ms))
58
+ }
59
+
17
60
  // Analytics are only sent if the transaction commits. Aborting or throwing an
18
61
  // exception from the tx will result in analytics NOT being sent to mixpanel.
19
62
  export class DatabaseAPIWithAnalytics extends DatabaseAPI {
@@ -64,13 +107,26 @@ export class DatabaseAPIWithAnalytics extends DatabaseAPI {
64
107
  updatesByUser[uid][key] = value
65
108
  }
66
109
 
110
+ /**
111
+ * Flushes everything queued by logAnalyticsEvent() and
112
+ * updateAnalyticsUserProfile() to Mixpanel.
113
+ *
114
+ * Never throws. This normally runs from postCommit(), i.e. after the
115
+ * transaction has already committed, so a Mixpanel failure here means the
116
+ * request did its work and only the telemetry about it was lost. It used to
117
+ * reject instead, which handed the caller a 500 (transport failure) or a 551
118
+ * (bad response) for a request that had succeeded: the write landed and the
119
+ * app still showed an error, and Mixpanel answering "temporary error ... try
120
+ * again in 30 seconds" was enough to do it. Failures are retried, then
121
+ * reported to Sentry as a warning and dropped.
122
+ */
67
123
  async sendAnalyticsEvents () {
68
124
  const events = this.__analyticsEvents
69
125
  this.__analyticsEvents = []
70
126
  const userProfileUpdates = this.__analyticsUserProfileUpdates
71
127
  this.__analyticsUserProfileUpdates = {}
72
128
 
73
- const promises = []
129
+ const calls = []
74
130
  if (events.length) {
75
131
  const uaData = {}
76
132
  const parser = new UAParser(this.req.headers['user-agent'])
@@ -100,14 +156,8 @@ export class DatabaseAPIWithAnalytics extends DatabaseAPI {
100
156
  Object.assign(x.properties, uaData)
101
157
  }
102
158
  // send all the events in one Mixpanel API call
103
- promises.push(this.callAPI({
104
- method: 'POST',
105
- url: 'https://api.mixpanel.com/track',
106
- headers: { accept: 'text/plain' },
107
- body: events
108
- }))
159
+ calls.push(this.__makeMixpanelCall('track', mixpanelTrackURL, events))
109
160
  }
110
- const sent = []
111
161
  for (const type of Object.keys(userProfileUpdates)) {
112
162
  const updatesByUser = userProfileUpdates[type]
113
163
  const body = []
@@ -119,28 +169,99 @@ export class DatabaseAPIWithAnalytics extends DatabaseAPI {
119
169
  [type]: updates
120
170
  })
121
171
  }
122
- promises.push(this.__sendUserProfileUpdates(type, body))
123
- sent.push({ type, body })
172
+ const url = mixpanelUpdateProfileURLs[type]
173
+ assert(url) // make sure a valid type was passed
174
+ calls.push(this.__makeMixpanelCall(type, url, body))
124
175
  }
125
- const responses = await Promise.all(promises)
126
- for (let i = 0; i < responses.length; i++) {
127
- const resp = responses[i]
128
- if (!resp.isOk || resp.data !== 1) {
129
- console.log('error response from mixpanel', resp, sent)
130
- throw new EXCEPTIONS.RequestError(
131
- 'failed to log analytics', { resp, sent }, 551)
176
+
177
+ const results = await Promise.all(
178
+ calls.map(call => this.__sendToMixpanel(call)))
179
+ const failures = results.filter(Boolean)
180
+ if (failures.length) {
181
+ this.__reportAnalyticsFailures(failures)
182
+ }
183
+ }
184
+
185
+ __makeMixpanelCall (type, url, body) {
186
+ return {
187
+ sent: { type, body },
188
+ request: {
189
+ method: 'POST',
190
+ url,
191
+ headers: { accept: 'text/plain' },
192
+ body
193
+ }
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Sends one Mixpanel request, retrying the failures a retry can fix.
199
+ *
200
+ * @returns {Object|undefined} undefined if the call succeeded, otherwise a
201
+ * description of the final failure
202
+ * @private
203
+ */
204
+ async __sendToMixpanel ({ request, sent }) {
205
+ const maxAttempts = MIXPANEL_RETRY_DELAYS_MS.length + 1
206
+ for (let attempt = 1; ; attempt++) {
207
+ const failure = await this.__attemptMixpanelCall(request, sent)
208
+ if (!failure) {
209
+ return undefined
132
210
  }
211
+ if (!failure.retryable || attempt === maxAttempts) {
212
+ return { ...failure, attempts: attempt }
213
+ }
214
+ await sleep(MIXPANEL_RETRY_DELAYS_MS[attempt - 1])
133
215
  }
134
216
  }
135
217
 
136
- async __sendUserProfileUpdates (type, body) {
137
- const mixpanelApiURL = mixpanelUpdateProfileURLs[type]
138
- assert(mixpanelApiURL) // make sure a valid type was passed
139
- return this.callAPI({
140
- method: 'POST',
141
- url: mixpanelApiURL,
142
- headers: { accept: 'text/plain' },
143
- body
218
+ /**
219
+ * Makes one attempt at a Mixpanel request.
220
+ *
221
+ * A rejected fetch (ECONNRESET, socket hang up) and a 5xx from Mixpanel are
222
+ * both transient, so they are worth another attempt. Anything else means
223
+ * Mixpanel understood us and said no -- a 4xx, or the `1` we expect in the
224
+ * body coming back as `0` -- which would fail identically on a retry.
225
+ *
226
+ * @returns {Object|undefined} undefined if the call succeeded, otherwise a
227
+ * description of the failure
228
+ * @private
229
+ */
230
+ async __attemptMixpanelCall (request, sent) {
231
+ let resp
232
+ try {
233
+ resp = await this.callAPI(request)
234
+ } catch (err) {
235
+ return { url: request.url, sent, err: String(err), retryable: true }
236
+ }
237
+ if (resp.isOk && resp.data === 1) {
238
+ return undefined
239
+ }
240
+ return {
241
+ url: request.url,
242
+ sent,
243
+ resp,
244
+ retryable: !resp.isOk && resp.code >= 500
245
+ }
246
+ }
247
+
248
+ __reportAnalyticsFailures (failures) {
249
+ // One key per kind of failure, so an outage and a payload Mixpanel refuses
250
+ // are suppressed (and grouped in Sentry) separately.
251
+ const key = failures
252
+ .map(f => `${f.url} ${f.err ? 'transport error' : f.resp.code}`)
253
+ .sort().join(', ')
254
+ console.log('failed to send analytics to mixpanel', key, failures)
255
+ if (!shouldReportAnalyticsFailure(key)) {
256
+ return
257
+ }
258
+ Sentry.withScope(scope => {
259
+ // a warning, not an error: the request itself succeeded
260
+ scope.setLevel('warning')
261
+ scope.setFingerprint(['mixpanel-egress-failed', key])
262
+ scope.setTags({ method: this.req.method, url: this.req.url })
263
+ scope.setExtras({ failures, reqId: this.req.id })
264
+ Sentry.captureException(new Error(`failed to log analytics: ${key}`))
144
265
  })
145
266
  }
146
267
  }
@@ -1,22 +1,70 @@
1
1
  import crypto from 'node:crypto'
2
2
 
3
- import { mixpanelToken } from '../src/analytics.js'
3
+ import {
4
+ mixpanelToken, resetAnalyticsFailureReporting, shouldReportAnalyticsFailure
5
+ } from '../src/analytics.js'
4
6
 
5
- import { AppTest, runTests } from './base-test.js'
7
+ import { AppTest, BaseTest, runTests } from './base-test.js'
6
8
 
7
9
  class AnalyticsTest extends AppTest {
8
10
  async beforeEach () {
9
11
  await super.beforeEach()
10
12
  // mock using node-fetch to request the mixpanel APIs
11
13
  this.fetchMock.mockResp(1)
14
+ resetAnalyticsFailureReporting()
12
15
  }
13
16
 
14
- async testMixpanelAPIFailure () {
17
+ // Mixpanel understood the request and refused it, so a retry would be
18
+ // refused too. The request still succeeds: only the telemetry is lost.
19
+ async testMixpanelRejectionIsNotRetried () {
15
20
  this.fetchMock.mockResp(0)
16
- await this.sendBasicEvent(null, 551)
21
+ await this.sendBasicEvent()
22
+ expect(this.fetchMock.mock.calls.length).toBe(1)
17
23
 
24
+ this.fetchMock.mockClear()
25
+ this.fetchMock.mockResp('', 400)
26
+ await this.sendBasicEvent()
27
+ expect(this.fetchMock.mock.calls.length).toBe(1)
28
+ }
29
+
30
+ // A 5xx is transient (Mixpanel's own 502 body says to try again), so it is
31
+ // retried until the attempts run out.
32
+ async testMixpanelServerErrorIsRetried () {
18
33
  this.fetchMock.mockResp('', 500)
19
- await this.sendBasicEvent(null, 551)
34
+ await this.sendBasicEvent()
35
+ expect(this.fetchMock.mock.calls.length).toBe(3)
36
+
37
+ // the second failure is the same kind as the first, so it is suppressed
38
+ // rather than reported to Sentry again
39
+ this.fetchMock.mockClear()
40
+ await this.sendBasicEvent()
41
+ expect(this.fetchMock.mock.calls.length).toBe(3)
42
+ }
43
+
44
+ // The failure that started this: node-fetch rejects before there is any
45
+ // response to inspect, e.g. "read ECONNRESET".
46
+ async testTransportFailureIsRetriedThenDropped () {
47
+ this.fetchMock.mockImplementation(
48
+ () => Promise.reject(new Error('read ECONNRESET')))
49
+ await this.sendBasicEvent()
50
+ expect(this.fetchMock.mock.calls.length).toBe(3)
51
+ }
52
+
53
+ async testTransportFailureRecoversOnRetry () {
54
+ let attempts = 0
55
+ this.fetchMock.mockImplementation(async () => {
56
+ attempts += 1
57
+ if (attempts === 1) {
58
+ throw new Error('socket hang up')
59
+ }
60
+ return {
61
+ status: 200,
62
+ headers: { get: () => 'application/json' },
63
+ text: async () => '1'
64
+ }
65
+ })
66
+ await this.sendBasicEvent()
67
+ expect(attempts).toBe(2)
20
68
  }
21
69
 
22
70
  async testNoAnalyticsLogged () {
@@ -277,4 +325,22 @@ class AnalyticsTest extends AppTest {
277
325
  }
278
326
  }
279
327
 
280
- runTests(AnalyticsTest)
328
+ class AnalyticsFailureReportingTest extends BaseTest {
329
+ beforeEach () {
330
+ resetAnalyticsFailureReporting()
331
+ }
332
+
333
+ testSuppressesRepeatsInsideTheWindow () {
334
+ const key = 'some failure'
335
+ expect(shouldReportAnalyticsFailure(key, 1000)).toBe(true)
336
+ expect(shouldReportAnalyticsFailure(key, 1000)).toBe(false)
337
+ // a different kind of failure is tracked separately
338
+ expect(shouldReportAnalyticsFailure('another failure', 1000)).toBe(true)
339
+ // and a suppressed report does not push the window out
340
+ const fiveMinutesLater = 1000 + 5 * 60 * 1000
341
+ expect(shouldReportAnalyticsFailure(key, fiveMinutesLater - 1)).toBe(false)
342
+ expect(shouldReportAnalyticsFailure(key, fiveMinutesLater)).toBe(true)
343
+ }
344
+ }
345
+
346
+ runTests(AnalyticsTest, AnalyticsFailureReportingTest)