@pbvision/cloud-run-service 0.0.29 → 0.0.31

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/.env CHANGED
@@ -1,5 +1,5 @@
1
1
  CLOUD_TASKS_EMULATOR_PORT=8123
2
- FIRESTORE_EMULATOR_HOST=[::1]:8404
2
+ FIRESTORE_EMULATOR_HOST=127.0.0.1:9091
3
3
  GCLOUD_PROJECT=localhost-emulator
4
4
  K_REVISION=unittest
5
5
  LOCAL_SERVICE_PORT_MAP='{}'
@@ -33,6 +33,7 @@
33
33
  "gserviceaccount",
34
34
  "INDEBUGGER",
35
35
  "oidc",
36
- "pino"
36
+ "pino",
37
+ "uids"
37
38
  ]
38
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pbvision/cloud-run-service",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
4
4
  "description": "fastify-firestore-service Web Framework on Cloud Run",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -10,11 +10,11 @@
10
10
  "exports": "./src/index.js",
11
11
  "scripts": {
12
12
  "coverage": "yarn -s start-local-db && yarn -s test --coverage",
13
- "debug": "./node_modules/nodemon/bin/nodemon.js --no-lazy --legacy-watch --watch ./src --watch ./test --inspect=9229 node --experimental-vm-modules --exec yarn test --runInBand",
13
+ "debug": "yarn start-local-db && INDEBUGGER=1 ./node_modules/nodemon/bin/nodemon.js --no-lazy --legacy-watch --watch ./src --watch ./test --exec 'node --inspect=9229 --env-file=.env --experimental-vm-modules ./node_modules/jest/bin/jest.js --config=./jest.config.json --runInBand'",
14
14
  "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
15
15
  "setup": "yarn install --frozen-lockfile",
16
16
  "start-local-db": "./node_modules/@pbvision/firestore-orm/scripts/start-local-db.sh",
17
- "start-local": "yarn -s start-local-db && K_REVISION=localhost node --env-file=.env test/main.js",
17
+ "start-local": "yarn -s start-local-db && K_REVISION=localhost PORT=8090 node --env-file=.env test/main.js",
18
18
  "test": "yarn -s start-local-db && yarn -s test-without-starting-db",
19
19
  "test-without-starting-db": "node --env-file=.env --experimental-vm-modules ./node_modules/jest/bin/jest.js --config=./jest.config.json",
20
20
  "watch": "yarn -s test --watch"
@@ -29,7 +29,8 @@
29
29
  "dependencies": {
30
30
  "@google-cloud/tasks": "^4.0.1",
31
31
  "@pbvision/fastify-firestore-service": "^0.0.38",
32
- "google-auth-library": "^9.4.2"
32
+ "google-auth-library": "^9.4.2",
33
+ "ua-parser-js": "^1.0.37"
33
34
  },
34
35
  "devDependencies": {
35
36
  "@babel/core": "^7.17.12",
@@ -46,6 +47,7 @@
46
47
  "eslint-plugin-promise": "^6.0.0",
47
48
  "firebase-tools": "^13.5.2",
48
49
  "jest": "^29.7.0",
50
+ "nodemon": "^3.1.0",
49
51
  "standard": "^17.1.0",
50
52
  "superagent": "^8",
51
53
  "superagent-defaults": "^0.1.14",
@@ -0,0 +1,151 @@
1
+ import assert from 'node:assert'
2
+ import { randomUUID } from 'node:crypto'
3
+
4
+ import { DatabaseAPI, EXCEPTIONS } from '@pbvision/fastify-firestore-service'
5
+ import UAParser from 'ua-parser-js'
6
+
7
+ import { isProd } from './utils.js'
8
+
9
+ // istanbul ignore next
10
+ export const mixpanelToken = isProd ? '78c48e38f59ab21c1850740e2bb4ecff' : '52bd993b07bdba759c2f141345e7c32a'
11
+
12
+ const mixpanelUpdateProfileURLs = {
13
+ $set: 'https://api.mixpanel.com/engage#profile-set',
14
+ $set_once: 'https://api.mixpanel.com/engage#profile-set-once'
15
+ }
16
+
17
+ // Analytics are only sent if the transaction commits. Aborting or throwing an
18
+ // exception from the tx will result in analytics NOT being sent to mixpanel.
19
+ export class DatabaseAPIWithAnalytics extends DatabaseAPI {
20
+ constructor (fastify, req, reply) {
21
+ super(fastify, req, reply)
22
+ this.__analyticsEvents = []
23
+ this.__analyticsUserProfileUpdates = {} // uid to $set/$set_once to changes
24
+ }
25
+
26
+ async postCommit (respData) {
27
+ // send analytics events after committing
28
+ await this.sendAnalyticsEvents()
29
+ return super.postCommit(respData)
30
+ }
31
+
32
+ logAnalyticsEvent (mixpanelUserId, eventName, inputProperties, deviceId = null) {
33
+ this.__analyticsEvents.push({
34
+ event: eventName,
35
+ properties: addSenderId(mixpanelUserId, {
36
+ ...inputProperties,
37
+ token: mixpanelToken,
38
+ time: new Date().getTime(),
39
+ $insert_id: randomUUID(),
40
+ ip: this.req.ip
41
+ }, deviceId)
42
+ })
43
+ }
44
+
45
+ updateAnalyticsUserProfile (uid, key, value, method = '$set') {
46
+ assert(mixpanelUpdateProfileURLs[method])
47
+ // should be our user id not a device id... profile data is not recommended
48
+ // for anonymous users
49
+ assert(!uid.startsWith('$device:'))
50
+ if (!this.__analyticsUserProfileUpdates[method]) {
51
+ this.__analyticsUserProfileUpdates[method] = {}
52
+ }
53
+ const updatesByUser = this.__analyticsUserProfileUpdates[method]
54
+ if (!updatesByUser[uid]) {
55
+ updatesByUser[uid] = {}
56
+ }
57
+ updatesByUser[uid][key] = value
58
+ }
59
+
60
+ async sendAnalyticsEvents () {
61
+ const events = this.__analyticsEvents
62
+ this.__analyticsEvents = []
63
+ const userProfileUpdates = this.__analyticsUserProfileUpdates
64
+ this.__analyticsUserProfileUpdates = {}
65
+
66
+ const promises = []
67
+ if (events.length) {
68
+ const uaData = {}
69
+ const parser = new UAParser(this.req.headers['user-agent'])
70
+ const browser = parser.getBrowser()
71
+ if (browser.name) {
72
+ uaData.$browser = browser.name
73
+ // istanbul ignore else
74
+ if (browser.version) {
75
+ uaData.$browser += ` ${browser.version}`
76
+ }
77
+ }
78
+ const device = parser.getDevice()
79
+ const $device = [device.vendor, device.model, device.type].filter(x => !!x).join(' ')
80
+ if ($device) {
81
+ uaData.$device = $device
82
+ }
83
+ const os = parser.getOS()
84
+ if (os.name) {
85
+ uaData.$os = os.name
86
+ // istanbul ignore else
87
+ if (os.version) {
88
+ uaData.$os += ` ${os.version}`
89
+ }
90
+ }
91
+
92
+ for (const x of events) {
93
+ Object.assign(x.properties, uaData)
94
+ }
95
+ // send all the events in one Mixpanel API call
96
+ promises.push(this.callAPI({
97
+ method: 'POST',
98
+ url: 'https://api.mixpanel.com/track',
99
+ headers: { accept: 'text/plain' },
100
+ body: events
101
+ }))
102
+ }
103
+ const sent = []
104
+ for (const type of Object.keys(userProfileUpdates)) {
105
+ const updatesByUser = userProfileUpdates[type]
106
+ const body = []
107
+ for (const uid of Object.keys(updatesByUser)) {
108
+ const updates = updatesByUser[uid]
109
+ body.push({
110
+ $distinct_id: uid,
111
+ $token: mixpanelToken,
112
+ [type]: updates
113
+ })
114
+ }
115
+ promises.push(this.__sendUserProfileUpdates(type, body))
116
+ sent.push({ type, body })
117
+ }
118
+ const responses = await Promise.all(promises)
119
+ for (let i = 0; i < responses.length; i++) {
120
+ const resp = responses[i]
121
+ if (!resp.isOk || resp.data !== 1) {
122
+ console.log('error response from mixpanel', resp, sent)
123
+ throw new EXCEPTIONS.RequestError(
124
+ 'failed to log analytics', { resp, sent }, 551)
125
+ }
126
+ }
127
+ }
128
+
129
+ async __sendUserProfileUpdates (type, body) {
130
+ const mixpanelApiURL = mixpanelUpdateProfileURLs[type]
131
+ assert(mixpanelApiURL) // make sure a valid type was passed
132
+ return this.callAPI({
133
+ method: 'POST',
134
+ url: mixpanelApiURL,
135
+ headers: { accept: 'text/plain' },
136
+ body
137
+ })
138
+ }
139
+ }
140
+
141
+ function addSenderId (mixpanelUserId, properties, deviceId) {
142
+ if (mixpanelUserId.startsWith('$device:')) {
143
+ properties.$device_id = mixpanelUserId
144
+ } else {
145
+ properties.$user_id = mixpanelUserId
146
+ if (deviceId) {
147
+ properties.$device_id = deviceId
148
+ }
149
+ }
150
+ return properties
151
+ }
package/src/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { DatabaseAPIWithAnalytics, mixpanelToken } from './analytics.js'
1
2
  import { makeService, runService } from './main.js'
2
3
  import { enqueueCloudTask } from './tasks.js'
3
4
  import { isCloud, isDev, isProd, isLocalhost, isUnitTesting, getServiceProtocolAndHost } from './utils.js'
@@ -7,6 +8,10 @@ export {
7
8
  makeService, // for use with unit testing
8
9
  runService,
9
10
 
11
+ // analytics
12
+ DatabaseAPIWithAnalytics, mixpanelToken,
13
+
14
+ // utility
10
15
  getServiceProtocolAndHost,
11
16
  isCloud, isDev, isProd, isLocalhost, isUnitTesting
12
17
  }
@@ -6,7 +6,9 @@ import { API, DatabaseAPI } from '@pbvision/fastify-firestore-service'
6
6
  import db from '@pbvision/firestore-orm'
7
7
  import S from '@pbvision/schema'
8
8
 
9
- import { isUnitTesting } from './utils.js'
9
+ import { isLocalhost, isUnitTesting } from './utils.js'
10
+
11
+ import { DatabaseAPIWithAnalytics } from './index.js'
10
12
 
11
13
  class Test extends db.Model {
12
14
  static KEY = { id: S.str }
@@ -43,3 +45,27 @@ export class TestCallServiceAPI extends API {
43
45
  }
44
46
  }
45
47
  }
48
+
49
+ export class TestAnalyticsAPI extends DatabaseAPIWithAnalytics {
50
+ static PATH = '/analytics'
51
+ static DESC = 'This is used by unit tests only to test DatabaseAPIWithAnalytics.'
52
+ static BODY = S.obj()
53
+
54
+ async computeResponse () {
55
+ // istanbul ignore next
56
+ assert(isUnitTesting || isLocalhost)
57
+ const { eventCalls, profileUpdates, moreEventCalls } = this.req.body
58
+ for (const x of (eventCalls ?? [])) {
59
+ this.logAnalyticsEvent(...x)
60
+ }
61
+ for (const x of (profileUpdates ?? [])) {
62
+ this.updateAnalyticsUserProfile(...x)
63
+ }
64
+ if (moreEventCalls) {
65
+ await this.sendAnalyticsEvents()
66
+ for (const x of moreEventCalls) {
67
+ this.logAnalyticsEvent(...x)
68
+ }
69
+ }
70
+ }
71
+ }
package/test/base-test.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { BaseAppTest, BaseTest, runTests } from '../node_modules/@pbvision/fastify-firestore-service/test/base-test.js'
2
- const { TestAPI, TestCallServiceAPI } = await import('../src/placeholder.js')
3
2
 
3
+ const { TestAnalyticsAPI, TestAPI, TestCallServiceAPI } = await import('../src/placeholder.js')
4
4
  export {
5
5
  BaseTest, runTests
6
6
  }
@@ -8,6 +8,10 @@ export {
8
8
  export class AppTest extends BaseAppTest {
9
9
  async getMakeServiceFunc () {
10
10
  const { makeService } = await import('../src/main.js')
11
- return () => makeService({ TestAPI, TestCallServiceAPI })
11
+ return () => makeService({
12
+ TestAnalyticsAPI,
13
+ TestAPI,
14
+ TestCallServiceAPI
15
+ })
12
16
  }
13
17
  }
package/test/main.js CHANGED
@@ -1,4 +1,4 @@
1
1
  import { runService } from '../src/index.js'
2
- const { TestAPI, TestCallServiceAPI } = await import('../src/placeholder.js')
2
+ const { TestAnalyticsAPI, TestAPI, TestCallServiceAPI } = await import('../src/placeholder.js')
3
3
 
4
- await runService({ TestAPI, TestCallServiceAPI })
4
+ await runService({ TestAnalyticsAPI, TestAPI, TestCallServiceAPI })
@@ -0,0 +1,235 @@
1
+ import { mixpanelToken } from '../src/analytics.js'
2
+
3
+ import { AppTest, runTests } from './base-test.js'
4
+
5
+ class AnalyticsTest extends AppTest {
6
+ async beforeEach () {
7
+ await super.beforeEach()
8
+ // mock using node-fetch to request the mixpanel APIs
9
+ this.fetchMock.mockResp(1)
10
+ }
11
+
12
+ async testMixpanelAPIFailure () {
13
+ this.fetchMock.mockResp(0)
14
+ await this.sendBasicEvent(null, 551)
15
+
16
+ this.fetchMock.mockResp('', 500)
17
+ await this.sendBasicEvent(null, 551)
18
+ }
19
+
20
+ async testNoAnalyticsLogged () {
21
+ await this.app.post('/analytics')
22
+ .send({})
23
+ .expect(200)
24
+ expect(this.fetchMock).not.toHaveBeenCalled()
25
+ }
26
+
27
+ async testSendingEvents () {
28
+ await this.sendBasicEvent()
29
+ expect(this.fetchMock).toHaveBeenCalledWith(
30
+ 'https://api.mixpanel.com/track',
31
+ expect.objectContaining({
32
+ headers: { accept: 'text/plain', 'content-type': 'application/json' },
33
+ method: 'POST',
34
+ compress: true
35
+ }))
36
+ const calls = this.fetchMock.mock.calls
37
+ expect(calls.length).toBe(1)
38
+ const actualBody = JSON.parse(calls[0][1].body)
39
+ expect(actualBody).toEqual([{
40
+ event: 'some event',
41
+ properties: expect.objectContaining({
42
+ cool: 1,
43
+ hi: 'world',
44
+ $user_id: 'some uid',
45
+ token: mixpanelToken
46
+ })
47
+ }])
48
+ const props = actualBody[0].properties
49
+ expect(typeof props.ip).toBe('string')
50
+ expect(new Date().getTime() - props.time).toBeLessThan(5000)
51
+ expect(props.$insert_id).toMatch(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/)
52
+ }
53
+
54
+ async sendBasicEvent (userAgent, expRespCode = 200) {
55
+ const req = this.app.post('/analytics')
56
+ if (userAgent) {
57
+ req.set('User-Agent', userAgent)
58
+ }
59
+ await req.send({
60
+ eventCalls: [
61
+ ['some uid', 'some event', { cool: 1, hi: 'world' }]
62
+ ]
63
+ }).expect(expRespCode)
64
+ }
65
+
66
+ async testSendInBatches () {
67
+ await this.app.post('/analytics')
68
+ .send({
69
+ eventCalls: [
70
+ ['xyz', 'some event', { cool: 1, hi: 'world' }]
71
+ ],
72
+ moreEventCalls: [
73
+ ['xyz', 'another event', { x: 2 }]
74
+ ]
75
+ }).expect(200)
76
+ const calls = this.fetchMock.mock.calls
77
+ expect(calls.length).toBe(2)
78
+ const actualBody = JSON.parse(calls[0][1].body)
79
+ expect(actualBody).toEqual([{
80
+ event: 'some event',
81
+ properties: expect.objectContaining({
82
+ cool: 1,
83
+ hi: 'world',
84
+ $user_id: 'xyz',
85
+ token: mixpanelToken
86
+ })
87
+ }])
88
+ const actualBody2 = JSON.parse(calls[1][1].body)
89
+ expect(actualBody2).toEqual([{
90
+ event: 'another event',
91
+ properties: expect.objectContaining({
92
+ x: 2,
93
+ $user_id: 'xyz',
94
+ token: mixpanelToken
95
+ })
96
+ }])
97
+ }
98
+
99
+ async testDeviceId () {
100
+ await this.app.post('/analytics')
101
+ .send({
102
+ eventCalls: [
103
+ ['$device:xyz', 'some event', { cool: 1, hi: 'world' }]
104
+ ]
105
+ }).expect(200)
106
+ const calls = this.fetchMock.mock.calls
107
+ expect(calls.length).toBe(1)
108
+ const actualBody = JSON.parse(calls[0][1].body)
109
+ expect(actualBody).toEqual([{
110
+ event: 'some event',
111
+ properties: expect.objectContaining({
112
+ cool: 1,
113
+ hi: 'world',
114
+ $device_id: '$device:xyz',
115
+ token: mixpanelToken
116
+ })
117
+ }])
118
+ }
119
+
120
+ async testDeviceIdWithUserId () {
121
+ await this.app.post('/analytics')
122
+ .send({
123
+ eventCalls: [
124
+ ['some uid', 'some event', { cool: 1, hi: 'world' }, '$device:xyz']
125
+ ]
126
+ }).expect(200)
127
+ const calls = this.fetchMock.mock.calls
128
+ expect(calls.length).toBe(1)
129
+ const actualBody = JSON.parse(calls[0][1].body)
130
+ expect(actualBody).toEqual([{
131
+ event: 'some event',
132
+ properties: expect.objectContaining({
133
+ cool: 1,
134
+ hi: 'world',
135
+ $user_id: 'some uid',
136
+ $device_id: '$device:xyz',
137
+ token: mixpanelToken
138
+ })
139
+ }])
140
+ }
141
+
142
+ async testUserAgent () {
143
+ await this.sendBasicEvent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36')
144
+ const calls = this.fetchMock.mock.calls
145
+ expect(calls.length).toBe(1)
146
+ const actualBody = JSON.parse(calls[0][1].body)
147
+ const props = actualBody[0].properties
148
+ expect(props.$browser).toBe('Chrome 122.0.0.0')
149
+ expect(props.$device).toBe(undefined)
150
+ expect(props.$os).toBe('Windows 10')
151
+ }
152
+
153
+ async testUserAgentWithDevice () {
154
+ await this.sendBasicEvent('Mozilla/5.0 (PlayBook; U; RIM Tablet OS 1.0.0; en-US) AppleWebKit/534.11 (KHTML, like Gecko) Version/7.1.0.7 Safari/534.11')
155
+ const calls = this.fetchMock.mock.calls
156
+ expect(calls.length).toBe(1)
157
+ const actualBody = JSON.parse(calls[0][1].body)
158
+ const props = actualBody[0].properties
159
+ expect(props.$browser).toBe('Safari 7.1.0.7')
160
+ expect(props.$device).toBe('RIM PlayBook tablet')
161
+ expect(props.$os).toBe('RIM Tablet OS 1.0.0')
162
+ }
163
+
164
+ async sendUserProfileUpdate (profileUpdates) {
165
+ const req = this.app.post('/analytics')
166
+ await req.send({ profileUpdates }).expect(200)
167
+ }
168
+
169
+ async testUserProfileUpdates () {
170
+ const uids = ['uid0', 'uid1']
171
+ await this.sendUserProfileUpdate([
172
+ [uids[0], 'p1', true],
173
+ [uids[0], 'p2', 5],
174
+ [uids[1], 'p2', 6],
175
+ [uids[1], 'p1', 'nope', '$set'],
176
+ [uids[1], 'p1', 'cool'],
177
+ [uids[1], 'p1', 'once', '$set_once'],
178
+ [uids[0], 'p1', false],
179
+ [uids[0], 'p3', 'hi']
180
+ ])
181
+ const calls = this.fetchMock.mock.calls
182
+ expect(calls.length).toBe(2)
183
+ const setURL = 'https://api.mixpanel.com/engage#profile-set'
184
+ const setOnceURL = 'https://api.mixpanel.com/engage#profile-set-once'
185
+ expect(this.fetchMock).toHaveBeenCalledWith(
186
+ setURL,
187
+ expect.objectContaining({
188
+ headers: { accept: 'text/plain', 'content-type': 'application/json' },
189
+ method: 'POST',
190
+ compress: true
191
+ }))
192
+ expect(this.fetchMock).toHaveBeenCalledWith(
193
+ setOnceURL,
194
+ expect.objectContaining({
195
+ headers: { accept: 'text/plain', 'content-type': 'application/json' },
196
+ method: 'POST',
197
+ compress: true
198
+ }))
199
+ // sort $set first, then $set_once (just so we have a consistent order for
200
+ // the test to check)
201
+ calls.sort((a, b) => a[0].localeCompare(b[0]))
202
+
203
+ // check $set updates
204
+ const actualSetUpdates = JSON.parse(calls[0][1].body)
205
+ actualSetUpdates.sort((a, b) => a.$distinct_id.localeCompare(b.$distinct_id))
206
+ const expectedSets = [
207
+ { p1: false, p2: 5, p3: 'hi' },
208
+ { p1: 'cool', p2: 6 }
209
+ ]
210
+ for (let i = 0; i < actualSetUpdates.length; i++) {
211
+ const actualArgs = actualSetUpdates[i]
212
+ expect(actualArgs).toEqual({
213
+ $token: mixpanelToken,
214
+ $distinct_id: uids[i],
215
+ $set: expectedSets[i]
216
+ })
217
+ }
218
+
219
+ // check $set_once updates
220
+ const actualSetOnceUpdates = JSON.parse(calls[1][1].body)
221
+ const expectedSetOnces = [
222
+ { p1: 'once' }
223
+ ]
224
+ for (let i = 0; i < actualSetOnceUpdates.length; i++) {
225
+ const actualArgs = actualSetOnceUpdates[i]
226
+ expect(actualArgs).toEqual({
227
+ $token: mixpanelToken,
228
+ $distinct_id: uids[1],
229
+ $set_once: expectedSetOnces[i]
230
+ })
231
+ }
232
+ }
233
+ }
234
+
235
+ runTests(AnalyticsTest)