Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add XHR interception #1150

Merged
merged 5 commits into from
Sep 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cyan-oranges-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@segment/analytics-signals': patch
---

Support XHR interception
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type FulfillOptions = Parameters<Route['fulfill']>['0']

export class BasePage {
protected page!: Page
static defaultTestApiURL = 'http://localhost:5432/api/foo'
public lastSignalsApiReq!: Request
public signalsApiReqs: SegmentEvent[] = []
public lastTrackingApiReq!: Request
Expand Down Expand Up @@ -69,7 +70,7 @@ export class BasePage {
({ signalSettings }) => {
window.signalsPlugin = new window.SignalsPlugin({
disableSignalsRedaction: true,
flushInterval: 500,
flushInterval: 1000,
...signalSettings,
})
window.analytics.load({
Expand Down Expand Up @@ -191,8 +192,11 @@ export class BasePage {
)
}

async mockTestRoute(url?: string, response?: Partial<FulfillOptions>) {
await this.page.route(url || 'http://localhost:5432/api/foo', (route) => {
async mockTestRoute(
url = BasePage.defaultTestApiURL,
response?: Partial<FulfillOptions>
) {
await this.page.route(url, (route) => {
return route.fulfill({
contentType: 'application/json',
status: 200,
Expand All @@ -203,12 +207,13 @@ export class BasePage {
}

async makeFetchCall(
url?: string,
url = BasePage.defaultTestApiURL,
request?: Partial<RequestInit>
): Promise<void> {
return this.page.evaluate(
const req = this.page.waitForRequest(url)
await this.page.evaluate(
({ url, request }) => {
return fetch(url || 'http://localhost:5432/api/foo', {
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand All @@ -221,6 +226,30 @@ export class BasePage {
},
{ url, request }
)
await req
}

async makeXHRCall(
url = BasePage.defaultTestApiURL,
request: Partial<{
method: string
body: any
contentType: string
responseType: XMLHttpRequestResponseType
}> = {}
): Promise<void> {
const req = this.page.waitForRequest(url)
await this.page.evaluate(
({ url, body, contentType, method, responseType }) => {
const xhr = new XMLHttpRequest()
xhr.open(method ?? 'POST', url)
xhr.responseType = responseType ?? 'json'
xhr.setRequestHeader('Content-Type', contentType ?? 'application/json')
xhr.send(body || JSON.stringify({ foo: 'bar' }))
},
{ url, ...request }
)
await req
}

waitForSignalsApiFlush(timeout = 5000) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,32 @@ test('network signals', async () => {
expect(responses[0].properties!.data.data).toEqual({ someResponse: 'yep' })
})

test('network signals xhr', async () => {
/**
* Make a fetch call, see if it gets sent to the signals endpoint
*/
await indexPage.mockTestRoute()
await indexPage.makeXHRCall()
await indexPage.waitForSignalsApiFlush()
const batch = indexPage.lastSignalsApiReq.postDataJSON()
.batch as SegmentEvent[]
const networkEvents = batch.filter(
(el: SegmentEvent) => el.properties!.type === 'network'
)
expect(networkEvents).toHaveLength(2)
const requests = networkEvents.filter(
(el) => el.properties!.data.action === 'request'
)
expect(requests).toHaveLength(1)
expect(requests[0].properties!.data.data).toEqual({ foo: 'bar' })

const responses = networkEvents.filter(
(el) => el.properties!.data.action === 'response'
)
expect(responses).toHaveLength(1)
expect(responses[0].properties!.data.data).toEqual({ someResponse: 'yep' })
})

test('instrumentation signals', async () => {
/**
* Make an analytics.page() call, see if it gets sent to the signals endpoint
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { test, expect } from '@playwright/test'
import { IndexPage } from './index-page'
import { SegmentEvent } from '@segment/analytics-next'

const basicEdgeFn = `
// this is a process signal function
const processSignal = (signal) => {
if (signal.type === 'interaction') {
const eventName = signal.data.eventType + ' ' + '[' + signal.type + ']'
analytics.track(eventName, signal.data)
}
}`

test.describe('XHR Tests', () => {
let indexPage: IndexPage

test.beforeEach(async ({ page }) => {
indexPage = new IndexPage()
await indexPage.loadAndWait(page, basicEdgeFn)
})
test('should not emit anything if neither request nor response are json', async () => {
await indexPage.mockTestRoute('http://localhost/test', {
body: 'hello',
contentType: 'application/text',
})

await indexPage.makeXHRCall('http://localhost/test', {
method: 'POST',
body: 'hello world',
contentType: 'application/text',
responseType: 'text',
})

// Wait for the signals to be flushed
await indexPage.waitForSignalsApiFlush()

// Retrieve the batch of events from the signals request
const batch = indexPage.lastSignalsApiReq.postDataJSON()
.batch as SegmentEvent[]

// Filter out network events
const networkEvents = batch.filter(
(el) => el.properties!.type === 'network'
)

// Ensure no request or response was captured
expect(networkEvents).toHaveLength(0)
})

test('works with XHR', async () => {
await indexPage.mockTestRoute('http://localhost/test', {
body: JSON.stringify({ foo: 'test' }),
})

await indexPage.makeXHRCall('http://localhost/test', {
method: 'POST',
body: JSON.stringify({ key: 'value' }),
contentType: 'application/json',
})

// Wait for the signals to be flushed
await indexPage.waitForSignalsApiFlush()

// Retrieve the batch of events from the signals request
const batch = indexPage.lastSignalsApiReq.postDataJSON()
.batch as SegmentEvent[]

// Filter out network events
const networkEvents = batch.filter(
(el) => el.properties!.type === 'network'
)

// Check the request
const requests = networkEvents.filter(
(el) => el.properties!.data.action === 'request'
)
expect(requests).toHaveLength(1)
expect(requests[0].properties!.data).toMatchObject({
action: 'request',
url: 'http://localhost/test',
data: { key: 'value' },
})

// Check the response
const responses = networkEvents.filter(
(el) => el.properties!.data.action === 'response'
)
expect(responses).toHaveLength(1)
expect(responses[0].properties!.data).toMatchObject({
action: 'response',
url: 'http://localhost/test',
data: { foo: 'test' },
})
})

test('should emit response but not request if request content-type is not json but response is', async () => {
await indexPage.mockTestRoute('http://localhost/test', {
body: JSON.stringify({ foo: 'test' }),
contentType: 'application/json',
})

await indexPage.makeXHRCall('http://localhost/test', {
method: 'POST',
body: 'hello world',
contentType: 'application/text',
})

// Wait for the signals to be flushed
await indexPage.waitForSignalsApiFlush()

// Retrieve the batch of events from the signals request
const batch = indexPage.lastSignalsApiReq.postDataJSON()
.batch as SegmentEvent[]

// Filter out network events
const networkEvents = batch.filter(
(el) => el.properties!.type === 'network'
)

// Check the response (only response should be captured)
const responses = networkEvents.filter(
(el) => el.properties!.data.action === 'response'
)
expect(responses).toHaveLength(1)
expect(responses[0].properties!.data).toMatchObject({
action: 'response',
url: 'http://localhost/test',
data: { foo: 'test' },
})

// Ensure no request was captured
const requests = networkEvents.filter(
(el) => el.properties!.data.action === 'request'
)
expect(requests).toHaveLength(0)
})

test('should parse response if responseType is set to json but response header does not contain application/json', async () => {
await indexPage.mockTestRoute('http://localhost/test', {
body: '{"hello": "world"}',
})

await indexPage.makeXHRCall('http://localhost/test', {
method: 'GET',
})

// Wait for the signals to be flushed
await indexPage.waitForSignalsApiFlush()

// Retrieve the batch of events from the signals request
const batch = indexPage.lastSignalsApiReq.postDataJSON()
.batch as SegmentEvent[]

// Filter out network events
const networkEvents = batch.filter(
(el) => el.properties!.type === 'network'
)

// Check the response
const responses = networkEvents.filter(
(el) => el.properties!.data.action === 'response'
)
expect(responses).toHaveLength(1)
expect(responses[0].properties!.data).toMatchObject({
action: 'response',
url: 'http://localhost/test',
data: { hello: 'world' },
})
})

test('will not emit response if error', async () => {
await indexPage.mockTestRoute('http://localhost/test', {
status: 400,
body: JSON.stringify({ error: 'error' }),
})

await indexPage.makeXHRCall('http://localhost/test', {
method: 'POST',
body: JSON.stringify({ key: 'value' }),
contentType: 'application/json',
})

// Wait for the signals to be flushed
await indexPage.waitForSignalsApiFlush()

// Retrieve the batch of events from the signals request
const batch = indexPage.lastSignalsApiReq.postDataJSON()
.batch as SegmentEvent[]

// Filter out network events
const networkEvents = batch.filter(
(el) => el.properties!.type === 'network'
)

// Check the request
const requests = networkEvents.filter(
(el) => el.properties!.data.action === 'request'
)
expect(requests).toHaveLength(1)
expect(requests[0].properties!.data).toMatchObject({
action: 'request',
url: 'http://localhost/test',
})

// Ensure no response was captured
const responses = networkEvents.filter(
(el) => el.properties!.data.action === 'response'
)
expect(responses).toHaveLength(0)
})
})
1 change: 1 addition & 0 deletions packages/signals/signals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ analytics.load({

```


### Debugging
#### Enable debug mode
Values sent to the signals API are redacted by default.
Expand Down
Loading
Loading