@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 +3 -2
- package/src/analytics.js +147 -26
- package/test/unit-test-analytics.js +72 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pbvision/cloud-run-service",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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)
|