@segment/analytics-browser-actions-utils 1.0.0

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 ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@segment/analytics-browser-actions-utils",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "main": "./dist/cjs",
6
+ "module": "./dist/esm",
7
+ "scripts": {
8
+ "build": "yarn build:esm && yarn build:cjs",
9
+ "build:cjs": "tsc --module commonjs --outDir ./dist/cjs",
10
+ "build:esm": "tsc --outDir ./dist/esm"
11
+ },
12
+ "typings": "./dist/esm",
13
+ "dependencies": {
14
+ "@segment/browser-destination-runtime": "^1.0.0"
15
+ },
16
+ "peerDependencies": {
17
+ "@segment/analytics-next": "*"
18
+ }
19
+ }
@@ -0,0 +1,25 @@
1
+ import { Analytics, Context } from '@segment/analytics-next'
2
+ import segmentUtilitiesDestination, { destination } from '../index'
3
+
4
+ describe('Segment Utilities Web', () => {
5
+ test('loads the destination', async () => {
6
+ const [throttle] = await segmentUtilitiesDestination({
7
+ throttleWindow: 3000,
8
+ passThroughCount: 1,
9
+ subscriptions: [
10
+ {
11
+ partnerAction: 'throttle',
12
+ name: 'Throttle',
13
+ enabled: true,
14
+ subscribe: 'type = "track"',
15
+ mapping: {}
16
+ }
17
+ ]
18
+ })
19
+
20
+ jest.spyOn(destination, 'initialize')
21
+
22
+ await throttle.load(Context.system(), {} as Analytics)
23
+ expect(destination.initialize).toHaveBeenCalled()
24
+ })
25
+ })
@@ -0,0 +1,12 @@
1
+ // Generated file. DO NOT MODIFY IT BY HAND.
2
+
3
+ export interface Settings {
4
+ /**
5
+ * The window of time in milliseconds to throttle events.
6
+ */
7
+ throttleWindow?: number
8
+ /**
9
+ * Number of events to pass through while waiting for the throttle time to expire.
10
+ */
11
+ passThroughCount?: number
12
+ }
package/src/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { Settings } from './generated-types'
2
+ import type { BrowserDestinationDefinition } from '@segment/browser-destination-runtime/types'
3
+ import { browserDestination } from '@segment/browser-destination-runtime/shim'
4
+
5
+ import throttle from './throttle'
6
+
7
+ export type SegmentUtilitiesInstance = {
8
+ eventMap: Record<string, { windowStarted: number; receivedCount: number }>
9
+ }
10
+
11
+ // Switch from unknown to the partner SDK client types
12
+ export const destination: BrowserDestinationDefinition<Settings, SegmentUtilitiesInstance> = {
13
+ name: 'Segment Utilities Web',
14
+ slug: 'actions-segment-utilities-web',
15
+ mode: 'device',
16
+
17
+ settings: {
18
+ throttleWindow: {
19
+ label: 'Throttle time',
20
+ description: 'The window of time in milliseconds to throttle events.',
21
+ type: 'number',
22
+ default: 3000
23
+ },
24
+ passThroughCount: {
25
+ label: 'Number of events to pass through',
26
+ description: 'Number of events to pass through while waiting for the throttle time to expire.',
27
+ type: 'number',
28
+ default: 1
29
+ }
30
+ },
31
+
32
+ initialize: async () => {
33
+ return {
34
+ eventMap: {}
35
+ }
36
+ },
37
+
38
+ actions: {
39
+ throttle
40
+ }
41
+ }
42
+
43
+ export default browserDestination(destination)
@@ -0,0 +1,379 @@
1
+ import { Analytics, Context } from '@segment/analytics-next'
2
+ import segmentUtilities from '../../index'
3
+
4
+ let ajs: Analytics
5
+
6
+ describe('throttle', () => {
7
+ beforeEach(async () => {
8
+ ajs = new Analytics({
9
+ writeKey: 'shall_never_be_revealed'
10
+ })
11
+ })
12
+
13
+ test('throttles events', async () => {
14
+ const [throttle] = await segmentUtilities({
15
+ throttleWindow: 3000,
16
+ passThroughCount: 1,
17
+ subscriptions: [
18
+ {
19
+ partnerAction: 'throttle',
20
+ name: 'Throttle',
21
+ enabled: true,
22
+ subscribe: 'type = "track"',
23
+ mapping: {}
24
+ }
25
+ ]
26
+ })
27
+
28
+ await throttle.load(Context.system(), ajs)
29
+ let ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
30
+
31
+ expect(ctx?.event.integrations).not.toBeDefined()
32
+
33
+ ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
34
+
35
+ expect(ctx?.event.integrations).toBeDefined()
36
+ expect(ctx?.event.integrations?.['Segment.io']).toEqual(false)
37
+ })
38
+
39
+ test('does not throttle if no settings provided', async () => {
40
+ const [throttle] = await segmentUtilities({
41
+ subscriptions: [
42
+ {
43
+ partnerAction: 'throttle',
44
+ name: 'Throttle',
45
+ enabled: true,
46
+ subscribe: 'type = "track"',
47
+ mapping: {}
48
+ }
49
+ ]
50
+ })
51
+
52
+ await throttle.load(Context.system(), ajs)
53
+ let ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
54
+
55
+ expect(ctx?.event.integrations).not.toBeDefined()
56
+
57
+ ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
58
+
59
+ expect(ctx?.event.integrations).not.toBeDefined()
60
+ })
61
+
62
+ test('accept overrides', async () => {
63
+ const [throttle] = await segmentUtilities({
64
+ throttleWindow: 3000,
65
+ passThroughCount: 1,
66
+ subscriptions: [
67
+ {
68
+ partnerAction: 'throttle',
69
+ name: 'Throttle',
70
+ enabled: true,
71
+ subscribe: 'type = "track"',
72
+ mapping: {
73
+ passThroughCount: 2
74
+ }
75
+ }
76
+ ]
77
+ })
78
+
79
+ await throttle.load(Context.system(), ajs)
80
+ let ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
81
+
82
+ expect(ctx?.event.integrations).not.toBeDefined()
83
+
84
+ ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
85
+
86
+ expect(ctx?.event.integrations).not.toBeDefined()
87
+
88
+ ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
89
+
90
+ expect(ctx?.event.integrations).toBeDefined()
91
+ expect(ctx?.event.integrations?.['Segment.io']).toEqual(false)
92
+ })
93
+
94
+ test('ignores invalid overrides', async () => {
95
+ const [throttle] = await segmentUtilities({
96
+ throttleWindow: 3000,
97
+ passThroughCount: 1,
98
+ subscriptions: [
99
+ {
100
+ partnerAction: 'throttle',
101
+ name: 'Throttle',
102
+ enabled: true,
103
+ subscribe: 'type = "track"',
104
+ mapping: {
105
+ passThroughCount: {
106
+ '@path': '$.event'
107
+ }
108
+ }
109
+ }
110
+ ]
111
+ })
112
+
113
+ await throttle.load(Context.system(), ajs)
114
+ let ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
115
+
116
+ expect(ctx?.event.integrations).not.toBeDefined()
117
+
118
+ ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
119
+
120
+ expect(ctx?.event.integrations).toBeDefined()
121
+ expect(ctx?.event.integrations?.['Segment.io']).toEqual(false)
122
+ })
123
+
124
+ test('does not allow any event to pass through if the pass through count is 0', async () => {
125
+ const [throttle] = await segmentUtilities({
126
+ throttleWindow: 3000,
127
+ passThroughCount: 0,
128
+ subscriptions: [
129
+ {
130
+ partnerAction: 'throttle',
131
+ name: 'Throttle',
132
+ enabled: true,
133
+ subscribe: 'type = "track"',
134
+ mapping: {}
135
+ }
136
+ ]
137
+ })
138
+
139
+ await throttle.load(Context.system(), ajs)
140
+ let ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
141
+
142
+ expect(ctx?.event.integrations).toBeDefined()
143
+ expect(ctx?.event.integrations?.['Segment.io']).toEqual(false)
144
+
145
+ ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
146
+
147
+ expect(ctx?.event.integrations).toBeDefined()
148
+ expect(ctx?.event.integrations?.['Segment.io']).toEqual(false)
149
+ })
150
+
151
+ test('throttles multiple events names separately', async () => {
152
+ const [throttle] = await segmentUtilities({
153
+ throttleWindow: 3000,
154
+ passThroughCount: 1,
155
+ subscriptions: [
156
+ {
157
+ partnerAction: 'throttle',
158
+ name: 'Throttle',
159
+ enabled: true,
160
+ subscribe: 'type = "track"',
161
+ mapping: {}
162
+ }
163
+ ]
164
+ })
165
+
166
+ await throttle.load(Context.system(), ajs)
167
+ let ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
168
+
169
+ expect(ctx?.event.integrations).not.toBeDefined()
170
+
171
+ ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Stopped' }))
172
+ expect(ctx?.event.integrations).not.toBeDefined()
173
+
174
+ ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
175
+ expect(ctx?.event.integrations).toBeDefined()
176
+ expect(ctx?.event.integrations?.['Segment.io']).toEqual(false)
177
+
178
+ ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Stopped' }))
179
+ expect(ctx?.event.integrations).toBeDefined()
180
+ expect(ctx?.event.integrations?.['Segment.io']).toEqual(false)
181
+ })
182
+
183
+ test('Allow n events to pass through during the window', async () => {
184
+ const [throttle] = await segmentUtilities({
185
+ throttleWindow: 3000,
186
+ passThroughCount: 3,
187
+ subscriptions: [
188
+ {
189
+ partnerAction: 'throttle',
190
+ name: 'Throttle',
191
+ enabled: true,
192
+ subscribe: 'type = "track"',
193
+ mapping: {}
194
+ }
195
+ ]
196
+ })
197
+
198
+ await throttle.load(Context.system(), ajs)
199
+ let passedThrough = 0
200
+ for (let i = 0; i < 10; i++) {
201
+ const ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
202
+ if (ctx?.event.integrations?.['Segment.io'] !== false) {
203
+ passedThrough++
204
+ }
205
+ }
206
+
207
+ expect(passedThrough).toBe(3)
208
+
209
+ const [throttle2] = await segmentUtilities({
210
+ throttleWindow: 3000,
211
+ passThroughCount: 8,
212
+ subscriptions: [
213
+ {
214
+ partnerAction: 'throttle',
215
+ name: 'Throttle',
216
+ enabled: true,
217
+ subscribe: 'type = "track"',
218
+ mapping: {}
219
+ }
220
+ ]
221
+ })
222
+
223
+ await throttle2.load(Context.system(), ajs)
224
+ passedThrough = 0
225
+
226
+ for (let i = 0; i < 10; i++) {
227
+ const ctx = await throttle2.track?.(new Context({ type: 'track', event: 'Video Played' }))
228
+ if (ctx?.event.integrations?.['Segment.io'] !== false) {
229
+ passedThrough++
230
+ }
231
+ }
232
+
233
+ expect(passedThrough).toBe(8)
234
+ })
235
+
236
+ test('Preserves the integrations object while flipping the Segment destination to false', async () => {
237
+ const [throttle] = await segmentUtilities({
238
+ throttleWindow: 3000,
239
+ passThroughCount: 1,
240
+ subscriptions: [
241
+ {
242
+ partnerAction: 'throttle',
243
+ name: 'Throttle',
244
+ enabled: true,
245
+ subscribe: 'type = "track"',
246
+ mapping: {}
247
+ }
248
+ ]
249
+ })
250
+
251
+ const integrations = {
252
+ All: false,
253
+ 'Segment.io': true,
254
+ 'Google Analytics': true,
255
+ 'Customer.io': true,
256
+ Amplitude: {
257
+ sessionId: 12345
258
+ }
259
+ }
260
+
261
+ await throttle.load(Context.system(), ajs)
262
+ let ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played', integrations }))
263
+
264
+ expect(ctx?.event.integrations).toEqual(integrations)
265
+
266
+ ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played', integrations }))
267
+ expect(ctx?.event.integrations).toEqual({ ...integrations, 'Segment.io': false })
268
+ })
269
+
270
+ test('Time-based throttling', async () => {
271
+ const [throttle] = await segmentUtilities({
272
+ throttleWindow: 3000,
273
+ passThroughCount: 1,
274
+ subscriptions: [
275
+ {
276
+ partnerAction: 'throttle',
277
+ name: 'Throttle',
278
+ enabled: true,
279
+ subscribe: 'type = "track"',
280
+ mapping: {}
281
+ }
282
+ ]
283
+ })
284
+
285
+ await throttle.load(Context.system(), ajs)
286
+
287
+ // Canada Day
288
+ jest.spyOn(global.Date, 'now').mockImplementationOnce(() => new Date('2022-07-01T00:00:00.000Z').valueOf())
289
+
290
+ let ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
291
+
292
+ expect(ctx?.event.integrations).not.toBeDefined()
293
+
294
+ // One second later, throttle this event
295
+ jest.spyOn(global.Date, 'now').mockImplementationOnce(() => new Date('2022-07-01T00:00:01.000Z').valueOf())
296
+
297
+ ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
298
+
299
+ expect(ctx?.event.integrations).toBeDefined()
300
+ expect(ctx?.event.integrations?.['Segment.io']).toEqual(false)
301
+
302
+ // Three seconds later, let this event through
303
+ jest.spyOn(global.Date, 'now').mockImplementationOnce(() => new Date('2022-07-01T00:00:03.100Z').valueOf())
304
+
305
+ ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
306
+ expect(ctx?.event.integrations).not.toBeDefined()
307
+ })
308
+
309
+ test('Complex Time-based throttling', async () => {
310
+ const [throttle] = await segmentUtilities({
311
+ throttleWindow: 3000,
312
+ passThroughCount: 2,
313
+ subscriptions: [
314
+ {
315
+ partnerAction: 'throttle',
316
+ name: 'Throttle',
317
+ enabled: true,
318
+ subscribe: 'type = "track"',
319
+ mapping: {}
320
+ }
321
+ ]
322
+ })
323
+
324
+ await throttle.load(Context.system(), ajs)
325
+
326
+ // Canada Day
327
+ jest.spyOn(global.Date, 'now').mockImplementationOnce(() => new Date('2022-07-01T00:00:00.000Z').valueOf())
328
+ let ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
329
+ expect(ctx?.event.integrations).not.toBeDefined()
330
+
331
+ // One second later, still allowed because of the passThroughCount
332
+ jest.spyOn(global.Date, 'now').mockImplementationOnce(() => new Date('2022-07-01T00:00:01.000Z').valueOf())
333
+ ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
334
+ expect(ctx?.event.integrations).not.toBeDefined()
335
+
336
+ // One second later, throttle this event
337
+ jest.spyOn(global.Date, 'now').mockImplementationOnce(() => new Date('2022-07-01T00:00:02.000Z').valueOf())
338
+ ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
339
+ expect(ctx?.event.integrations).toBeDefined()
340
+ expect(ctx?.event.integrations?.['Segment.io']).toEqual(false)
341
+
342
+ // One second later, window expired, let this event through
343
+ jest.spyOn(global.Date, 'now').mockImplementationOnce(() => new Date('2022-07-01T00:00:03.100Z').valueOf())
344
+ ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
345
+ expect(ctx?.event.integrations).not.toBeDefined()
346
+ })
347
+
348
+ test('Only throttles the events that match the subscription', async () => {
349
+ const [throttle] = await segmentUtilities({
350
+ throttleWindow: 3000,
351
+ passThroughCount: 1,
352
+ subscriptions: [
353
+ {
354
+ partnerAction: 'throttle',
355
+ name: 'Throttle',
356
+ enabled: true,
357
+ subscribe: 'type = "track" and event = "Video Played"',
358
+ mapping: {}
359
+ }
360
+ ]
361
+ })
362
+
363
+ await throttle.load(Context.system(), ajs)
364
+ let ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
365
+
366
+ expect(ctx?.event.integrations).not.toBeDefined()
367
+
368
+ ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Played' }))
369
+ expect(ctx?.event.integrations).toBeDefined()
370
+ expect(ctx?.event.integrations?.['Segment.io']).toEqual(false)
371
+
372
+ ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Stopped' }))
373
+
374
+ expect(ctx?.event.integrations).not.toBeDefined()
375
+
376
+ ctx = await throttle.track?.(new Context({ type: 'track', event: 'Video Stopped' }))
377
+ expect(ctx?.event.integrations).not.toBeDefined()
378
+ })
379
+ })
@@ -0,0 +1,12 @@
1
+ // Generated file. DO NOT MODIFY IT BY HAND.
2
+
3
+ export interface Payload {
4
+ /**
5
+ * Override the global pass through count.
6
+ */
7
+ passThroughCount?: number | null
8
+ /**
9
+ * Override the global throttle time.
10
+ */
11
+ throttleWindow?: number | null
12
+ }
@@ -0,0 +1,69 @@
1
+ import { SegmentUtilitiesInstance } from '..'
2
+ import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types'
3
+ import type { Settings } from '../generated-types'
4
+ import type { Payload } from './generated-types'
5
+
6
+ const action: BrowserActionDefinition<Settings, SegmentUtilitiesInstance, Payload> = {
7
+ title: 'Throttle by event name',
8
+ description: 'Throttle events sent to Segment. Throttling is on a per event name basis.',
9
+ platform: 'web',
10
+ defaultSubscription: 'type = "track"',
11
+ fields: {
12
+ passThroughCount: {
13
+ label: 'Number of events to pass through',
14
+ description: 'Override the global pass through count.',
15
+ type: 'integer',
16
+ allowNull: true
17
+ },
18
+ throttleWindow: {
19
+ label: 'Throttle time',
20
+ description: 'Override the global throttle time.',
21
+ type: 'number',
22
+ allowNull: true
23
+ }
24
+ },
25
+ lifecycleHook: 'before',
26
+ perform: ({ eventMap }, data) => {
27
+ const { context, settings, payload } = data
28
+
29
+ const overridePassThroughCount = isNaN(Number(payload.passThroughCount))
30
+ ? undefined
31
+ : Number(payload.passThroughCount)
32
+ const overrideThrottleWindow = isNaN(Number(payload.throttleWindow)) ? undefined : Number(payload.throttleWindow)
33
+
34
+ const passThroughCount = overridePassThroughCount ?? settings.passThroughCount ?? 1
35
+ const throttleWindow = overrideThrottleWindow ?? settings.throttleWindow ?? 0
36
+ const event = context.event.event
37
+
38
+ if (!event) {
39
+ return
40
+ }
41
+
42
+ if (eventMap[event]) {
43
+ const { windowStarted } = eventMap[event]
44
+ const now = Date.now()
45
+ if (now - windowStarted < throttleWindow) {
46
+ // within the throttle window
47
+ eventMap[event].receivedCount++
48
+ } else {
49
+ eventMap[event] = {
50
+ // reset the window
51
+ windowStarted: Date.now(),
52
+ receivedCount: 1
53
+ }
54
+ }
55
+ } else {
56
+ // first time we've seen this event, start the window
57
+ eventMap[event] = {
58
+ windowStarted: Date.now(),
59
+ receivedCount: 1
60
+ }
61
+ }
62
+
63
+ if (eventMap[event].receivedCount > passThroughCount) {
64
+ context.updateEvent('integrations', { ...context.event.integrations, 'Segment.io': false })
65
+ }
66
+ }
67
+ }
68
+
69
+ export default action
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.build.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "baseUrl": "."
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["dist", "**/__tests__"]
9
+ }