Skip to content

Commit 04a7cc8

Browse files
authored
Add XHR interception (#1150)
1 parent 784ddf2 commit 04a7cc8

File tree

13 files changed

+618
-40
lines changed

13 files changed

+618
-40
lines changed

.changeset/cyan-oranges-swim.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@segment/analytics-signals': patch
3+
---
4+
5+
Support XHR interception

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

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ type FulfillOptions = Parameters<Route['fulfill']>['0']
88

99
export class BasePage {
1010
protected page!: Page
11+
static defaultTestApiURL = 'http://localhost:5432/api/foo'
1112
public lastSignalsApiReq!: Request
1213
public signalsApiReqs: SegmentEvent[] = []
1314
public lastTrackingApiReq!: Request
@@ -69,7 +70,7 @@ export class BasePage {
6970
({ signalSettings }) => {
7071
window.signalsPlugin = new window.SignalsPlugin({
7172
disableSignalsRedaction: true,
72-
flushInterval: 500,
73+
flushInterval: 1000,
7374
...signalSettings,
7475
})
7576
window.analytics.load({
@@ -191,8 +192,11 @@ export class BasePage {
191192
)
192193
}
193194

194-
async mockTestRoute(url?: string, response?: Partial<FulfillOptions>) {
195-
await this.page.route(url || 'http://localhost:5432/api/foo', (route) => {
195+
async mockTestRoute(
196+
url = BasePage.defaultTestApiURL,
197+
response?: Partial<FulfillOptions>
198+
) {
199+
await this.page.route(url, (route) => {
196200
return route.fulfill({
197201
contentType: 'application/json',
198202
status: 200,
@@ -203,12 +207,13 @@ export class BasePage {
203207
}
204208

205209
async makeFetchCall(
206-
url?: string,
210+
url = BasePage.defaultTestApiURL,
207211
request?: Partial<RequestInit>
208212
): Promise<void> {
209-
return this.page.evaluate(
213+
const req = this.page.waitForRequest(url)
214+
await this.page.evaluate(
210215
({ url, request }) => {
211-
return fetch(url || 'http://localhost:5432/api/foo', {
216+
return fetch(url, {
212217
method: 'POST',
213218
headers: {
214219
'Content-Type': 'application/json',
@@ -221,6 +226,30 @@ export class BasePage {
221226
},
222227
{ url, request }
223228
)
229+
await req
230+
}
231+
232+
async makeXHRCall(
233+
url = BasePage.defaultTestApiURL,
234+
request: Partial<{
235+
method: string
236+
body: any
237+
contentType: string
238+
responseType: XMLHttpRequestResponseType
239+
}> = {}
240+
): Promise<void> {
241+
const req = this.page.waitForRequest(url)
242+
await this.page.evaluate(
243+
({ url, body, contentType, method, responseType }) => {
244+
const xhr = new XMLHttpRequest()
245+
xhr.open(method ?? 'POST', url)
246+
xhr.responseType = responseType ?? 'json'
247+
xhr.setRequestHeader('Content-Type', contentType ?? 'application/json')
248+
xhr.send(body || JSON.stringify({ foo: 'bar' }))
249+
},
250+
{ url, ...request }
251+
)
252+
await req
224253
}
225254

226255
waitForSignalsApiFlush(timeout = 5000) {

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,32 @@ test('network signals', async () => {
4242
expect(responses[0].properties!.data.data).toEqual({ someResponse: 'yep' })
4343
})
4444

45+
test('network signals xhr', async () => {
46+
/**
47+
* Make a fetch call, see if it gets sent to the signals endpoint
48+
*/
49+
await indexPage.mockTestRoute()
50+
await indexPage.makeXHRCall()
51+
await indexPage.waitForSignalsApiFlush()
52+
const batch = indexPage.lastSignalsApiReq.postDataJSON()
53+
.batch as SegmentEvent[]
54+
const networkEvents = batch.filter(
55+
(el: SegmentEvent) => el.properties!.type === 'network'
56+
)
57+
expect(networkEvents).toHaveLength(2)
58+
const requests = networkEvents.filter(
59+
(el) => el.properties!.data.action === 'request'
60+
)
61+
expect(requests).toHaveLength(1)
62+
expect(requests[0].properties!.data.data).toEqual({ foo: 'bar' })
63+
64+
const responses = networkEvents.filter(
65+
(el) => el.properties!.data.action === 'response'
66+
)
67+
expect(responses).toHaveLength(1)
68+
expect(responses[0].properties!.data.data).toEqual({ someResponse: 'yep' })
69+
})
70+
4571
test('instrumentation signals', async () => {
4672
/**
4773
* Make an analytics.page() call, see if it gets sent to the signals endpoint
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { test, expect } from '@playwright/test'
2+
import { IndexPage } from './index-page'
3+
import { SegmentEvent } from '@segment/analytics-next'
4+
5+
const basicEdgeFn = `
6+
// this is a process signal function
7+
const processSignal = (signal) => {
8+
if (signal.type === 'interaction') {
9+
const eventName = signal.data.eventType + ' ' + '[' + signal.type + ']'
10+
analytics.track(eventName, signal.data)
11+
}
12+
}`
13+
14+
test.describe('XHR Tests', () => {
15+
let indexPage: IndexPage
16+
17+
test.beforeEach(async ({ page }) => {
18+
indexPage = new IndexPage()
19+
await indexPage.loadAndWait(page, basicEdgeFn)
20+
})
21+
test('should not emit anything if neither request nor response are json', async () => {
22+
await indexPage.mockTestRoute('http://localhost/test', {
23+
body: 'hello',
24+
contentType: 'application/text',
25+
})
26+
27+
await indexPage.makeXHRCall('http://localhost/test', {
28+
method: 'POST',
29+
body: 'hello world',
30+
contentType: 'application/text',
31+
responseType: 'text',
32+
})
33+
34+
// Wait for the signals to be flushed
35+
await indexPage.waitForSignalsApiFlush()
36+
37+
// Retrieve the batch of events from the signals request
38+
const batch = indexPage.lastSignalsApiReq.postDataJSON()
39+
.batch as SegmentEvent[]
40+
41+
// Filter out network events
42+
const networkEvents = batch.filter(
43+
(el) => el.properties!.type === 'network'
44+
)
45+
46+
// Ensure no request or response was captured
47+
expect(networkEvents).toHaveLength(0)
48+
})
49+
50+
test('works with XHR', async () => {
51+
await indexPage.mockTestRoute('http://localhost/test', {
52+
body: JSON.stringify({ foo: 'test' }),
53+
})
54+
55+
await indexPage.makeXHRCall('http://localhost/test', {
56+
method: 'POST',
57+
body: JSON.stringify({ key: 'value' }),
58+
contentType: 'application/json',
59+
})
60+
61+
// Wait for the signals to be flushed
62+
await indexPage.waitForSignalsApiFlush()
63+
64+
// Retrieve the batch of events from the signals request
65+
const batch = indexPage.lastSignalsApiReq.postDataJSON()
66+
.batch as SegmentEvent[]
67+
68+
// Filter out network events
69+
const networkEvents = batch.filter(
70+
(el) => el.properties!.type === 'network'
71+
)
72+
73+
// Check the request
74+
const requests = networkEvents.filter(
75+
(el) => el.properties!.data.action === 'request'
76+
)
77+
expect(requests).toHaveLength(1)
78+
expect(requests[0].properties!.data).toMatchObject({
79+
action: 'request',
80+
url: 'http://localhost/test',
81+
data: { key: 'value' },
82+
})
83+
84+
// Check the response
85+
const responses = networkEvents.filter(
86+
(el) => el.properties!.data.action === 'response'
87+
)
88+
expect(responses).toHaveLength(1)
89+
expect(responses[0].properties!.data).toMatchObject({
90+
action: 'response',
91+
url: 'http://localhost/test',
92+
data: { foo: 'test' },
93+
})
94+
})
95+
96+
test('should emit response but not request if request content-type is not json but response is', async () => {
97+
await indexPage.mockTestRoute('http://localhost/test', {
98+
body: JSON.stringify({ foo: 'test' }),
99+
contentType: 'application/json',
100+
})
101+
102+
await indexPage.makeXHRCall('http://localhost/test', {
103+
method: 'POST',
104+
body: 'hello world',
105+
contentType: 'application/text',
106+
})
107+
108+
// Wait for the signals to be flushed
109+
await indexPage.waitForSignalsApiFlush()
110+
111+
// Retrieve the batch of events from the signals request
112+
const batch = indexPage.lastSignalsApiReq.postDataJSON()
113+
.batch as SegmentEvent[]
114+
115+
// Filter out network events
116+
const networkEvents = batch.filter(
117+
(el) => el.properties!.type === 'network'
118+
)
119+
120+
// Check the response (only response should be captured)
121+
const responses = networkEvents.filter(
122+
(el) => el.properties!.data.action === 'response'
123+
)
124+
expect(responses).toHaveLength(1)
125+
expect(responses[0].properties!.data).toMatchObject({
126+
action: 'response',
127+
url: 'http://localhost/test',
128+
data: { foo: 'test' },
129+
})
130+
131+
// Ensure no request was captured
132+
const requests = networkEvents.filter(
133+
(el) => el.properties!.data.action === 'request'
134+
)
135+
expect(requests).toHaveLength(0)
136+
})
137+
138+
test('should parse response if responseType is set to json but response header does not contain application/json', async () => {
139+
await indexPage.mockTestRoute('http://localhost/test', {
140+
body: '{"hello": "world"}',
141+
})
142+
143+
await indexPage.makeXHRCall('http://localhost/test', {
144+
method: 'GET',
145+
})
146+
147+
// Wait for the signals to be flushed
148+
await indexPage.waitForSignalsApiFlush()
149+
150+
// Retrieve the batch of events from the signals request
151+
const batch = indexPage.lastSignalsApiReq.postDataJSON()
152+
.batch as SegmentEvent[]
153+
154+
// Filter out network events
155+
const networkEvents = batch.filter(
156+
(el) => el.properties!.type === 'network'
157+
)
158+
159+
// Check the response
160+
const responses = networkEvents.filter(
161+
(el) => el.properties!.data.action === 'response'
162+
)
163+
expect(responses).toHaveLength(1)
164+
expect(responses[0].properties!.data).toMatchObject({
165+
action: 'response',
166+
url: 'http://localhost/test',
167+
data: { hello: 'world' },
168+
})
169+
})
170+
171+
test('will not emit response if error', async () => {
172+
await indexPage.mockTestRoute('http://localhost/test', {
173+
status: 400,
174+
body: JSON.stringify({ error: 'error' }),
175+
})
176+
177+
await indexPage.makeXHRCall('http://localhost/test', {
178+
method: 'POST',
179+
body: JSON.stringify({ key: 'value' }),
180+
contentType: 'application/json',
181+
})
182+
183+
// Wait for the signals to be flushed
184+
await indexPage.waitForSignalsApiFlush()
185+
186+
// Retrieve the batch of events from the signals request
187+
const batch = indexPage.lastSignalsApiReq.postDataJSON()
188+
.batch as SegmentEvent[]
189+
190+
// Filter out network events
191+
const networkEvents = batch.filter(
192+
(el) => el.properties!.type === 'network'
193+
)
194+
195+
// Check the request
196+
const requests = networkEvents.filter(
197+
(el) => el.properties!.data.action === 'request'
198+
)
199+
expect(requests).toHaveLength(1)
200+
expect(requests[0].properties!.data).toMatchObject({
201+
action: 'request',
202+
url: 'http://localhost/test',
203+
})
204+
205+
// Ensure no response was captured
206+
const responses = networkEvents.filter(
207+
(el) => el.properties!.data.action === 'response'
208+
)
209+
expect(responses).toHaveLength(0)
210+
})
211+
})

packages/signals/signals/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ analytics.load({
3131

3232
```
3333

34+
3435
### Debugging
3536
#### Enable debug mode
3637
Values sent to the signals API are redacted by default.

0 commit comments

Comments
 (0)