Skip to content

Commit 784ddf2

Browse files
authored
Signals: add support for allow/disallow list, fix network signals bug (#1147)
1 parent 5647624 commit 784ddf2

File tree

29 files changed

+1001
-323
lines changed

29 files changed

+1001
-323
lines changed

.changeset/strong-rats-lay.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@segment/analytics-signals': minor
3+
---
4+
5+
Update network signals to add support for allow/disallow

packages/signals/signals-integration-tests/src/helpers/base-page-object.ts

Lines changed: 133 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { CDNSettingsBuilder } from '@internal/test-helpers'
2-
import { Page, Request } from '@playwright/test'
2+
import { Page, Request, Route } from '@playwright/test'
33
import { logConsole } from './log-console'
44
import { SegmentEvent } from '@segment/analytics-next'
5+
import { Signal, SignalsPluginSettingsConfig } from '@segment/analytics-signals'
6+
7+
type FulfillOptions = Parameters<Route['fulfill']>['0']
58

69
export class BasePage {
710
protected page!: Page
@@ -18,30 +21,67 @@ export class BasePage {
1821
this.url = 'http://localhost:5432/src/tests' + path
1922
}
2023

24+
/**
25+
* Load and setup routes
26+
* and wait for analytics and signals to be initialized
27+
*/
28+
async loadAndWait(...args: Parameters<BasePage['load']>) {
29+
await this.load(...args)
30+
await this.waitForSignalsAssets()
31+
return this
32+
}
33+
2134
/**
2235
* load and setup routes
23-
* @param page
24-
* @param edgeFn - edge function to be loaded
2536
*/
26-
async load(page: Page, edgeFn: string) {
37+
async load(
38+
page: Page,
39+
edgeFn: string,
40+
signalSettings: Partial<SignalsPluginSettingsConfig> = {}
41+
) {
2742
logConsole(page)
2843
this.page = page
2944
this.edgeFn = edgeFn
3045
await this.setupMockedRoutes()
3146
await this.page.goto(this.url)
47+
await this.invokeAnalyticsLoad(signalSettings)
3248
}
3349

3450
/**
35-
* Wait for analytics and signals to be initialized
36-
* Signals can be captured before this, so it's useful to have this method
51+
* Wait for analytics and signals to be initialized
3752
*/
38-
async waitForAnalyticsInit() {
53+
async waitForSignalsAssets() {
54+
// this is kind of an approximation of full initialization
3955
return Promise.all([
4056
this.waitForCDNSettingsResponse(),
4157
this.waitForEdgeFunctionResponse(),
4258
])
4359
}
4460

61+
/**
62+
* Invoke the analytics load sequence, but do not wait for analytics to full initialize
63+
* Full initialization means that the CDN settings and edge function have been loaded
64+
*/
65+
private async invokeAnalyticsLoad(
66+
signalSettings: Partial<SignalsPluginSettingsConfig> = {}
67+
) {
68+
await this.page.evaluate(
69+
({ signalSettings }) => {
70+
window.signalsPlugin = new window.SignalsPlugin({
71+
disableSignalsRedaction: true,
72+
flushInterval: 500,
73+
...signalSettings,
74+
})
75+
window.analytics.load({
76+
writeKey: '<SOME_WRITE_KEY>',
77+
plugins: [window.signalsPlugin],
78+
})
79+
},
80+
{ signalSettings }
81+
)
82+
return this
83+
}
84+
4585
private async setupMockedRoutes() {
4686
// clear any existing saved requests
4787
this.signalsApiReqs = []
@@ -97,6 +137,92 @@ export class BasePage {
97137
)
98138
}
99139

140+
async waitForSignalsEmit(
141+
filter: (signal: Signal) => boolean,
142+
{
143+
expectedSignalCount,
144+
maxTimeoutMs = 10000,
145+
failOnEmit = false,
146+
}: {
147+
expectedSignalCount?: number
148+
maxTimeoutMs?: number
149+
failOnEmit?: boolean
150+
} = {}
151+
) {
152+
return this.page.evaluate(
153+
([filter, expectedSignalCount, maxTimeoutMs, failOnEmit]) => {
154+
return new Promise((resolve, reject) => {
155+
let signalCount = 0
156+
const to = setTimeout(() => {
157+
if (failOnEmit) {
158+
resolve('No signal emitted')
159+
} else {
160+
reject('Timed out waiting for signals')
161+
}
162+
}, maxTimeoutMs)
163+
window.signalsPlugin.onSignal((signal) => {
164+
signalCount++
165+
if (
166+
eval(filter)(signal) &&
167+
signalCount === (expectedSignalCount ?? 1)
168+
) {
169+
if (failOnEmit) {
170+
reject(
171+
`Signal should not have been emitted: ${JSON.stringify(
172+
signal,
173+
null,
174+
2
175+
)}`
176+
)
177+
} else {
178+
resolve(signal)
179+
}
180+
clearTimeout(to)
181+
}
182+
})
183+
})
184+
},
185+
[
186+
filter.toString(),
187+
expectedSignalCount,
188+
maxTimeoutMs,
189+
failOnEmit,
190+
] as const
191+
)
192+
}
193+
194+
async mockTestRoute(url?: string, response?: Partial<FulfillOptions>) {
195+
await this.page.route(url || 'http://localhost:5432/api/foo', (route) => {
196+
return route.fulfill({
197+
contentType: 'application/json',
198+
status: 200,
199+
body: JSON.stringify({ someResponse: 'yep' }),
200+
...response,
201+
})
202+
})
203+
}
204+
205+
async makeFetchCall(
206+
url?: string,
207+
request?: Partial<RequestInit>
208+
): Promise<void> {
209+
return this.page.evaluate(
210+
({ url, request }) => {
211+
return fetch(url || 'http://localhost:5432/api/foo', {
212+
method: 'POST',
213+
headers: {
214+
'Content-Type': 'application/json',
215+
},
216+
body: JSON.stringify({ foo: 'bar' }),
217+
...request,
218+
})
219+
.then(console.log)
220+
.catch(console.error)
221+
},
222+
{ url, request }
223+
)
224+
}
225+
100226
waitForSignalsApiFlush(timeout = 5000) {
101227
return this.page.waitForResponse('https://signals.segment.io/v1/*', {
102228
timeout,

packages/signals/signals-integration-tests/src/tests/signals-vanilla/all-segment-events.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ test('Should dispatch events from signals that occurred before analytics was ins
7676
// add a user defined signal before analytics is instantiated
7777
void indexPage.addUserDefinedSignal()
7878

79-
await indexPage.waitForAnalyticsInit()
79+
await indexPage.waitForSignalsAssets()
8080

8181
await Promise.all([
8282
indexPage.waitForSignalsApiFlush(),

packages/signals/signals-integration-tests/src/tests/signals-vanilla/basic.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,15 @@ const basicEdgeFn = `
1414
}`
1515

1616
test.beforeEach(async ({ page }) => {
17-
await indexPage.load(page, basicEdgeFn)
18-
await indexPage.waitForAnalyticsInit()
17+
await indexPage.loadAndWait(page, basicEdgeFn)
1918
})
2019

2120
test('network signals', async () => {
2221
/**
2322
* Make a fetch call, see if it gets sent to the signals endpoint
2423
*/
25-
await indexPage.mockRandomJSONApi()
26-
await indexPage.makeFetchCallToRandomJSONApi()
24+
await indexPage.mockTestRoute()
25+
await indexPage.makeFetchCall()
2726
await indexPage.waitForSignalsApiFlush()
2827
const batch = indexPage.lastSignalsApiReq.postDataJSON()
2928
.batch as SegmentEvent[]

packages/signals/signals-integration-tests/src/tests/signals-vanilla/index-page.ts

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -33,32 +33,6 @@ export class IndexPage extends BasePage {
3333
})
3434
}
3535

36-
async mockRandomJSONApi() {
37-
await this.page.route('http://localhost:5432/api/foo', (route) => {
38-
return route.fulfill({
39-
contentType: 'application/json',
40-
status: 200,
41-
body: JSON.stringify({
42-
someResponse: 'yep',
43-
}),
44-
})
45-
})
46-
}
47-
48-
async makeFetchCallToRandomJSONApi(): Promise<void> {
49-
return this.page.evaluate(() => {
50-
return fetch('http://localhost:5432/api/foo', {
51-
method: 'POST',
52-
headers: {
53-
'Content-Type': 'application/json',
54-
},
55-
body: JSON.stringify({ foo: 'bar' }),
56-
})
57-
.then(console.log)
58-
.catch(console.error)
59-
})
60-
}
61-
6236
async clickButton() {
6337
return this.page.click('#some-button')
6438
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { test, expect } from '@playwright/test'
2+
import { IndexPage } from './index-page'
3+
import type { SegmentEvent } from '@segment/analytics-next'
4+
5+
const indexPage = new IndexPage()
6+
7+
const basicEdgeFn = `
8+
// this is a process signal function
9+
const processSignal = (signal) => {
10+
if (signal.type === 'interaction') {
11+
const eventName = signal.data.eventType + ' ' + '[' + signal.type + ']'
12+
analytics.track(eventName, signal.data)
13+
}
14+
}`
15+
16+
test('network signals allow and disallow list', async ({ page }) => {
17+
await indexPage.loadAndWait(page, basicEdgeFn, {
18+
networkSignalsAllowList: ['allowed-api.com'],
19+
networkSignalsDisallowList: ['https://disallowed-api.com/api/foo'],
20+
})
21+
22+
// test that the allowed signals were emitted + sent
23+
const ALLOWED_URL = 'https://allowed-api.com/api/bar'
24+
const emittedNetworkSignalsAllowed = indexPage.waitForSignalsEmit(
25+
(el) => el.type === 'network'
26+
)
27+
await indexPage.mockTestRoute(ALLOWED_URL)
28+
await indexPage.makeFetchCall(ALLOWED_URL)
29+
await emittedNetworkSignalsAllowed
30+
31+
await indexPage.waitForSignalsApiFlush()
32+
const batch = indexPage.lastSignalsApiReq.postDataJSON()
33+
.batch as SegmentEvent[]
34+
const networkEvents = batch.filter(
35+
(el: SegmentEvent) => el.properties!.type === 'network'
36+
)
37+
const allowedRequestsAndResponses = networkEvents.filter(
38+
(el) => el.properties!.data.url === ALLOWED_URL
39+
)
40+
expect(allowedRequestsAndResponses).toHaveLength(2)
41+
const [request, response] = allowedRequestsAndResponses
42+
expect(request.properties!.data.data).toEqual({
43+
foo: 'bar',
44+
})
45+
expect(response.properties!.data.data).toEqual({
46+
someResponse: 'yep',
47+
})
48+
49+
// test the disallowed signals were not emitted (using the emitter to test this)
50+
const DISALLOWED_URL = 'https://disallowed-api.com/api/foo'
51+
const emittedNetworkSignalsDisallowed = indexPage.waitForSignalsEmit(
52+
(el) => el.type === 'network',
53+
{
54+
failOnEmit: true,
55+
}
56+
)
57+
await indexPage.mockTestRoute(DISALLOWED_URL)
58+
await indexPage.makeFetchCall(DISALLOWED_URL)
59+
await emittedNetworkSignalsDisallowed
60+
})
Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import { AnalyticsBrowser } from '@segment/analytics-next'
22
import { SignalsPlugin } from '@segment/analytics-signals'
33

4-
const analytics = new AnalyticsBrowser()
5-
;(window as any).analytics = analytics
4+
declare global {
5+
interface Window {
6+
analytics: AnalyticsBrowser
7+
SignalsPlugin: typeof SignalsPlugin
8+
signalsPlugin: SignalsPlugin
9+
}
10+
}
611

7-
const signalsPlugin = new SignalsPlugin({
8-
disableSignalsRedaction: true,
9-
})
10-
11-
;(window as any).signalsPlugin = signalsPlugin
12-
13-
analytics.load({
14-
writeKey: '<SOME_WRITE_KEY>',
15-
plugins: [signalsPlugin],
16-
})
12+
/**
13+
* Not instantiating the analytics object here, as it will be instantiated in the test
14+
*/
15+
;(window as any).SignalsPlugin = SignalsPlugin
16+
;(window as any).analytics = new AnalyticsBrowser()

packages/signals/signals/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"devDependencies": {
6060
"@internal/config-webpack": "workspace:^",
6161
"@internal/test-helpers": "workspace:^",
62-
"fake-indexeddb": "^6.0.0"
62+
"fake-indexeddb": "^6.0.0",
63+
"node-fetch": "^2.6.7"
6364
}
6465
}

packages/signals/signals/src/core/analytics-service/__tests__/analytics-service.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ describe(AnalyticsService, () => {
1717
})
1818

1919
it('should return the correct write key', () => {
20-
expect(service.writeKey).toBe('foo')
20+
expect(service.instance.settings.writeKey).toBe('foo')
2121
})
2222

2323
describe('createSegmentInstrumentationEventGenerator', () => {

packages/signals/signals/src/core/analytics-service/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,10 @@ type EdgeFunctionSettings = { downloadURL: string; version?: number }
88
* Helper / facade that wraps the analytics, and abstracts away the details of the analytics instance.
99
*/
1010
export class AnalyticsService {
11-
writeKey: string
1211
instance: AnyAnalytics
1312
edgeFnSettings?: EdgeFunctionSettings
1413
constructor(analyticsInstance: AnyAnalytics) {
1514
this.instance = analyticsInstance
16-
this.writeKey = analyticsInstance.settings.writeKey
1715
this.edgeFnSettings = this.parseEdgeFnSettings(
1816
analyticsInstance.settings.cdnSettings
1917
)

0 commit comments

Comments
 (0)