@pbvision/cloud-run-service 0.0.29 → 0.0.30

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.30",
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,154 @@
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
+ this.__analyticsSent = false
25
+ }
26
+
27
+ async postCommit (respData) {
28
+ // send analytics events after committing
29
+ await this.sendAnalyticsEvents()
30
+ return super.postCommit(respData)
31
+ }
32
+
33
+ logAnalyticsEvent (mixpanelUserId, eventName, inputProperties, deviceId = null) {
34
+ assert(this.__analyticsSent === false)
35
+ this.__analyticsEvents.push({
36
+ event: eventName,
37
+ properties: addSenderId(mixpanelUserId, {
38
+ ...inputProperties,
39
+ token: mixpanelToken,
40
+ time: new Date().getTime(),
41
+ $insert_id: randomUUID(),
42
+ ip: this.req.ip
43
+ }, deviceId)
44
+ })
45
+ }
46
+
47
+ updateAnalyticsUserProfile (uid, key, value, method = '$set') {
48
+ assert(mixpanelUpdateProfileURLs[method])
49
+ // should be our user id not a device id... profile data is not recommended
50
+ // for anonymous users
51
+ assert(!uid.startsWith('$device:'))
52
+ if (!this.__analyticsUserProfileUpdates[method]) {
53
+ this.__analyticsUserProfileUpdates[method] = {}
54
+ }
55
+ const updatesByUser = this.__analyticsUserProfileUpdates[method]
56
+ if (!updatesByUser[uid]) {
57
+ updatesByUser[uid] = {}
58
+ }
59
+ updatesByUser[uid][key] = value
60
+ }
61
+
62
+ async sendAnalyticsEvents () {
63
+ // istanbul ignore if
64
+ if (this.__analyticsSent) {
65
+ return
66
+ }
67
+ this.__analyticsSent = true
68
+
69
+ const promises = []
70
+ if (this.__analyticsEvents.length) {
71
+ const uaData = {}
72
+ const parser = new UAParser(this.req.headers['user-agent'])
73
+ const browser = parser.getBrowser()
74
+ if (browser.name) {
75
+ uaData.$browser = browser.name
76
+ // istanbul ignore else
77
+ if (browser.version) {
78
+ uaData.$browser += ` ${browser.version}`
79
+ }
80
+ }
81
+ const device = parser.getDevice()
82
+ const $device = [device.vendor, device.model, device.type].filter(x => !!x).join(' ')
83
+ if ($device) {
84
+ uaData.$device = $device
85
+ }
86
+ const os = parser.getOS()
87
+ if (os.name) {
88
+ uaData.$os = os.name
89
+ // istanbul ignore else
90
+ if (os.version) {
91
+ uaData.$os += ` ${os.version}`
92
+ }
93
+ }
94
+
95
+ for (const x of this.__analyticsEvents) {
96
+ Object.assign(x.properties, uaData)
97
+ }
98
+ // send all the events in one Mixpanel API call
99
+ promises.push(this.callAPI({
100
+ method: 'POST',
101
+ url: 'https://api.mixpanel.com/track',
102
+ headers: { accept: 'text/plain' },
103
+ body: this.__analyticsEvents
104
+ }))
105
+ }
106
+ const sent = []
107
+ for (const type of Object.keys(this.__analyticsUserProfileUpdates)) {
108
+ const updatesByUser = this.__analyticsUserProfileUpdates[type]
109
+ const body = []
110
+ for (const uid of Object.keys(updatesByUser)) {
111
+ const updates = updatesByUser[uid]
112
+ body.push({
113
+ $distinct_id: uid,
114
+ $token: mixpanelToken,
115
+ [type]: updates
116
+ })
117
+ }
118
+ promises.push(this.__sendUserProfileUpdates(type, body))
119
+ sent.push({ type, body })
120
+ }
121
+ const responses = await Promise.all(promises)
122
+ for (let i = 0; i < responses.length; i++) {
123
+ const resp = responses[i]
124
+ if (!resp.isOk || resp.data !== 1) {
125
+ console.log('error response from mixpanel', resp, sent)
126
+ throw new EXCEPTIONS.RequestError(
127
+ 'failed to log analytics', { resp, sent }, 551)
128
+ }
129
+ }
130
+ }
131
+
132
+ async __sendUserProfileUpdates (type, body) {
133
+ const mixpanelApiURL = mixpanelUpdateProfileURLs[type]
134
+ assert(mixpanelApiURL) // make sure a valid type was passed
135
+ return this.callAPI({
136
+ method: 'POST',
137
+ url: mixpanelApiURL,
138
+ headers: { accept: 'text/plain' },
139
+ body
140
+ })
141
+ }
142
+ }
143
+
144
+ function addSenderId (mixpanelUserId, properties, deviceId) {
145
+ if (mixpanelUserId.startsWith('$device:')) {
146
+ properties.$device_id = mixpanelUserId
147
+ } else {
148
+ properties.$user_id = mixpanelUserId
149
+ if (deviceId) {
150
+ properties.$device_id = deviceId
151
+ }
152
+ }
153
+ return properties
154
+ }
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,21 @@ 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 } = 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
+ }
65
+ }
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,202 @@
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 testDeviceId () {
67
+ await this.app.post('/analytics')
68
+ .send({
69
+ eventCalls: [
70
+ ['$device:xyz', 'some event', { cool: 1, hi: 'world' }]
71
+ ]
72
+ }).expect(200)
73
+ const calls = this.fetchMock.mock.calls
74
+ expect(calls.length).toBe(1)
75
+ const actualBody = JSON.parse(calls[0][1].body)
76
+ expect(actualBody).toEqual([{
77
+ event: 'some event',
78
+ properties: expect.objectContaining({
79
+ cool: 1,
80
+ hi: 'world',
81
+ $device_id: '$device:xyz',
82
+ token: mixpanelToken
83
+ })
84
+ }])
85
+ }
86
+
87
+ async testDeviceIdWithUserId () {
88
+ await this.app.post('/analytics')
89
+ .send({
90
+ eventCalls: [
91
+ ['some uid', 'some event', { cool: 1, hi: 'world' }, '$device:xyz']
92
+ ]
93
+ }).expect(200)
94
+ const calls = this.fetchMock.mock.calls
95
+ expect(calls.length).toBe(1)
96
+ const actualBody = JSON.parse(calls[0][1].body)
97
+ expect(actualBody).toEqual([{
98
+ event: 'some event',
99
+ properties: expect.objectContaining({
100
+ cool: 1,
101
+ hi: 'world',
102
+ $user_id: 'some uid',
103
+ $device_id: '$device:xyz',
104
+ token: mixpanelToken
105
+ })
106
+ }])
107
+ }
108
+
109
+ async testUserAgent () {
110
+ 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')
111
+ const calls = this.fetchMock.mock.calls
112
+ expect(calls.length).toBe(1)
113
+ const actualBody = JSON.parse(calls[0][1].body)
114
+ const props = actualBody[0].properties
115
+ expect(props.$browser).toBe('Chrome 122.0.0.0')
116
+ expect(props.$device).toBe(undefined)
117
+ expect(props.$os).toBe('Windows 10')
118
+ }
119
+
120
+ async testUserAgentWithDevice () {
121
+ 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')
122
+ const calls = this.fetchMock.mock.calls
123
+ expect(calls.length).toBe(1)
124
+ const actualBody = JSON.parse(calls[0][1].body)
125
+ const props = actualBody[0].properties
126
+ expect(props.$browser).toBe('Safari 7.1.0.7')
127
+ expect(props.$device).toBe('RIM PlayBook tablet')
128
+ expect(props.$os).toBe('RIM Tablet OS 1.0.0')
129
+ }
130
+
131
+ async sendUserProfileUpdate (profileUpdates) {
132
+ const req = this.app.post('/analytics')
133
+ await req.send({ profileUpdates }).expect(200)
134
+ }
135
+
136
+ async testUserProfileUpdates () {
137
+ const uids = ['uid0', 'uid1']
138
+ await this.sendUserProfileUpdate([
139
+ [uids[0], 'p1', true],
140
+ [uids[0], 'p2', 5],
141
+ [uids[1], 'p2', 6],
142
+ [uids[1], 'p1', 'nope', '$set'],
143
+ [uids[1], 'p1', 'cool'],
144
+ [uids[1], 'p1', 'once', '$set_once'],
145
+ [uids[0], 'p1', false],
146
+ [uids[0], 'p3', 'hi']
147
+ ])
148
+ const calls = this.fetchMock.mock.calls
149
+ expect(calls.length).toBe(2)
150
+ const setURL = 'https://api.mixpanel.com/engage#profile-set'
151
+ const setOnceURL = 'https://api.mixpanel.com/engage#profile-set-once'
152
+ expect(this.fetchMock).toHaveBeenCalledWith(
153
+ setURL,
154
+ expect.objectContaining({
155
+ headers: { accept: 'text/plain', 'content-type': 'application/json' },
156
+ method: 'POST',
157
+ compress: true
158
+ }))
159
+ expect(this.fetchMock).toHaveBeenCalledWith(
160
+ setOnceURL,
161
+ expect.objectContaining({
162
+ headers: { accept: 'text/plain', 'content-type': 'application/json' },
163
+ method: 'POST',
164
+ compress: true
165
+ }))
166
+ // sort $set first, then $set_once (just so we have a consistent order for
167
+ // the test to check)
168
+ calls.sort((a, b) => a[0].localeCompare(b[0]))
169
+
170
+ // check $set updates
171
+ const actualSetUpdates = JSON.parse(calls[0][1].body)
172
+ actualSetUpdates.sort((a, b) => a.$distinct_id.localeCompare(b.$distinct_id))
173
+ const expectedSets = [
174
+ { p1: false, p2: 5, p3: 'hi' },
175
+ { p1: 'cool', p2: 6 }
176
+ ]
177
+ for (let i = 0; i < actualSetUpdates.length; i++) {
178
+ const actualArgs = actualSetUpdates[i]
179
+ expect(actualArgs).toEqual({
180
+ $token: mixpanelToken,
181
+ $distinct_id: uids[i],
182
+ $set: expectedSets[i]
183
+ })
184
+ }
185
+
186
+ // check $set_once updates
187
+ const actualSetOnceUpdates = JSON.parse(calls[1][1].body)
188
+ const expectedSetOnces = [
189
+ { p1: 'once' }
190
+ ]
191
+ for (let i = 0; i < actualSetOnceUpdates.length; i++) {
192
+ const actualArgs = actualSetOnceUpdates[i]
193
+ expect(actualArgs).toEqual({
194
+ $token: mixpanelToken,
195
+ $distinct_id: uids[1],
196
+ $set_once: expectedSetOnces[i]
197
+ })
198
+ }
199
+ }
200
+ }
201
+
202
+ runTests(AnalyticsTest)