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 ability to pass custom http client into node #880

Merged
merged 24 commits into from
Jul 13, 2023
Merged
5 changes: 5 additions & 0 deletions .changeset/nervous-chefs-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@segment/analytics-node': minor
---

Add `httpClient` setting. This allow users to override default HTTP client with a custom one.
3 changes: 2 additions & 1 deletion packages/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
},
"devDependencies": {
"@internal/config": "0.0.0",
"@types/node": "^16"
"@types/node": "^16",
"axios": "^1.4.0"
},
"packageManager": "[email protected]"
}
20 changes: 9 additions & 11 deletions packages/node/src/__tests__/callback.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
const fetcher = jest.fn()
jest.mock('../lib/fetch', () => ({ fetch: fetcher }))

import { createError, createSuccess } from './test-helpers/factories'
import { createTestAnalytics } from './test-helpers/create-test-analytics'
import { Context } from '../app/context'

describe('Callback behavior', () => {
beforeEach(() => {
fetcher.mockReturnValue(createSuccess())
})

it('should handle success', async () => {
const ajs = createTestAnalytics({ maxEventsInBatch: 1 })
const ajs = createTestAnalytics({
maxEventsInBatch: 1,
})
const ctx = await new Promise<Context>((resolve, reject) =>
ajs.track(
{
Expand All @@ -29,8 +23,12 @@ describe('Callback behavior', () => {
})

it('should handle errors', async () => {
fetcher.mockReturnValue(createError())
const ajs = createTestAnalytics({ maxEventsInBatch: 1 })
const ajs = createTestAnalytics(
{
maxEventsInBatch: 1,
},
{ withError: true }
)
const [err, ctx] = await new Promise<[any, Context]>((resolve) =>
ajs.track(
{
Expand Down
25 changes: 15 additions & 10 deletions packages/node/src/__tests__/disable.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
const fetcher = jest.fn()
jest.mock('../lib/fetch', () => ({ fetch: fetcher }))

import { createTestAnalytics } from './test-helpers/create-test-analytics'
import {
createTestAnalytics,
TestFetchClient,
} from './test-helpers/create-test-analytics'

describe('disable', () => {
it('should dispatch callbacks and emit an http request, even if disabled', async () => {
const httpClient = new TestFetchClient()
const makeReqSpy = jest.spyOn(httpClient, 'makeRequest')

it('should not emit an http request if disabled', async () => {
const analytics = createTestAnalytics({
disable: true,
})
Expand All @@ -13,25 +16,27 @@ describe('disable', () => {
await new Promise((resolve) =>
analytics.track({ anonymousId: 'foo', event: 'bar' }, resolve)
)
expect(emitterCb).toBeCalledTimes(1)
expect(emitterCb).not.toBeCalled()
})

it('should call fetch if disabled is false', async () => {
it('should call .send if disabled is false', async () => {
const analytics = createTestAnalytics({
disable: false,
httpClient: httpClient,
})
await new Promise((resolve) =>
analytics.track({ anonymousId: 'foo', event: 'bar' }, resolve)
)
expect(fetcher).toBeCalled()
expect(makeReqSpy).toBeCalledTimes(1)
})
it('should not call fetch if disabled is true', async () => {
it('should not call .send if disabled is true', async () => {
const analytics = createTestAnalytics({
disable: true,
httpClient: httpClient,
})
await new Promise((resolve) =>
analytics.track({ anonymousId: 'foo', event: 'bar' }, resolve)
)
expect(fetcher).not.toBeCalled()
expect(makeReqSpy).not.toBeCalled()
})
})
21 changes: 12 additions & 9 deletions packages/node/src/__tests__/emitter.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
const fetcher = jest.fn()
jest.mock('../lib/fetch', () => ({ fetch: fetcher }))

import { createError, createSuccess } from './test-helpers/factories'
import { createTestAnalytics } from './test-helpers/create-test-analytics'
import { assertHttpRequestEmittedEvent } from './test-helpers/assert-shape'

describe('http_request', () => {
it('emits an http_request event if success', async () => {
fetcher.mockReturnValue(createSuccess())
const analytics = createTestAnalytics()
const fn = jest.fn()
analytics.on('http_request', fn)
Expand All @@ -19,8 +14,12 @@ describe('http_request', () => {
})

it('emits an http_request event if error', async () => {
fetcher.mockReturnValue(createError())
const analytics = createTestAnalytics({ maxRetries: 0 })
const analytics = createTestAnalytics(
{
maxRetries: 0,
},
{ withError: true }
)
const fn = jest.fn()
analytics.on('http_request', fn)
await new Promise((resolve) =>
Expand All @@ -30,8 +29,12 @@ describe('http_request', () => {
})

it('if error, emits an http_request event on every retry', async () => {
fetcher.mockReturnValue(createError())
const analytics = createTestAnalytics({ maxRetries: 2 })
const analytics = createTestAnalytics(
{
maxRetries: 2,
},
{ withError: true }
)
const fn = jest.fn()
analytics.on('http_request', fn)
await new Promise((resolve) =>
Expand Down
29 changes: 16 additions & 13 deletions packages/node/src/__tests__/graceful-shutdown-integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import { createSuccess } from './test-helpers/factories'
import { TestFetchClient } from './test-helpers/create-test-analytics'
import { performance as perf } from 'perf_hooks'

const fetcher = jest.fn()
jest.mock('../lib/fetch', () => ({ fetch: fetcher }))

import { Analytics } from '../app/analytics-node'
import { sleep } from './test-helpers/sleep'
import { Plugin, SegmentEvent } from '../app/types'
Expand All @@ -17,22 +13,26 @@ const testPlugin: Plugin = {
isLoaded: () => true,
}

let testClient: TestFetchClient

describe('Ability for users to exit without losing events', () => {
let ajs!: Analytics
testClient = new TestFetchClient()
const makeReqSpy = jest.spyOn(testClient, 'makeRequest')
beforeEach(async () => {
fetcher.mockReturnValue(createSuccess())
ajs = new Analytics({
writeKey: 'abc123',
maxEventsInBatch: 1,
httpClient: testClient,
})
})
const _helpers = {
getFetchCalls: (mockedFetchFn = fetcher) =>
mockedFetchFn.mock.calls.map(([url, request]) => ({
getFetchCalls: () =>
makeReqSpy.mock.calls.map(([{ url, method, data, headers }]) => ({
url,
method: request.method,
headers: request.headers,
body: JSON.parse(request.body),
method,
headers,
data,
})),
makeTrackCall: (analytics = ajs, cb?: (...args: any[]) => void) => {
analytics.track({ userId: 'foo', event: 'Thing Updated' }, cb)
Expand Down Expand Up @@ -89,6 +89,7 @@ describe('Ability for users to exit without losing events', () => {
ajs = new Analytics({
writeKey: 'abc123',
flushInterval,
httpClient: testClient,
})
const closeAndFlushTimeout = ajs['_closeAndFlushDefaultTimeout']
expect(closeAndFlushTimeout).toBe(flushInterval * 1.25)
Expand Down Expand Up @@ -190,6 +191,7 @@ describe('Ability for users to exit without losing events', () => {
writeKey: 'foo',
flushInterval: 10000,
maxEventsInBatch: 15,
httpClient: testClient,
})
_helpers.makeTrackCall(analytics)
_helpers.makeTrackCall(analytics)
Expand All @@ -204,7 +206,7 @@ describe('Ability for users to exit without losing events', () => {
expect(elapsedTime).toBeLessThan(100)
const calls = _helpers.getFetchCalls()
expect(calls.length).toBe(1)
expect(calls[0].body.batch.length).toBe(2)
expect(calls[0].data.batch.length).toBe(2)
})

test('should wait to flush if close is called and an event has not made it to the segment.io plugin yet', async () => {
Expand All @@ -220,6 +222,7 @@ describe('Ability for users to exit without losing events', () => {
writeKey: 'foo',
flushInterval: 10000,
maxEventsInBatch: 15,
httpClient: testClient,
})
await analytics.register(_testPlugin)
_helpers.makeTrackCall(analytics)
Expand All @@ -235,7 +238,7 @@ describe('Ability for users to exit without losing events', () => {
expect(elapsedTime).toBeLessThan(TRACK_DELAY * 2)
const calls = _helpers.getFetchCalls()
expect(calls.length).toBe(1)
expect(calls[0].body.batch.length).toBe(2)
expect(calls[0].data.batch.length).toBe(2)
})
})
})
69 changes: 69 additions & 0 deletions packages/node/src/__tests__/http-client.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { FetchHTTPClient, HTTPFetchFn } from '..'
import { AbortSignal as AbortSignalShim } from '../lib/abort'
import { httpClientOptionsBodyMatcher } from './test-helpers/assert-shape/segment-http-api'
import { createTestAnalytics } from './test-helpers/create-test-analytics'
import { createSuccess } from './test-helpers/factories'

const testFetch: jest.MockedFn<HTTPFetchFn> = jest
.fn()
.mockResolvedValue(createSuccess())

let analytics: ReturnType<typeof createTestAnalytics>

const helpers = {
makeTrackCall: () =>
new Promise((resolve) =>
analytics.track({ event: 'foo', userId: 'bar' }, resolve)
),
assertFetchCallRequest: (
...[url, options]: typeof testFetch['mock']['lastCall']
) => {
expect(url).toBe('https://api.segment.io/v1/batch')
expect(options.headers).toEqual({
Authorization: 'Basic Zm9vOg==',
'Content-Type': 'application/json',
'User-Agent': 'analytics-node-next/latest',
})
expect(options.method).toBe('POST')
const getLastBatch = (): object[] => {
const [, options] = testFetch.mock.lastCall
const batch = JSON.parse(options.body!).batch
return batch
}
const batch = getLastBatch()
expect(batch.length).toBe(1)
expect(batch[0]).toEqual({
...httpClientOptionsBodyMatcher,
timestamp: expect.stringMatching(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
),
properties: {},
event: 'foo',
type: 'track',
userId: 'bar',
})
// @ts-ignore
expect(options.signal).toBeInstanceOf(
typeof AbortSignal !== 'undefined' ? AbortSignal : AbortSignalShim
)
},
}

describe('httpClient option', () => {
it('can be a regular custom HTTP client', async () => {
analytics = createTestAnalytics({
httpClient: new FetchHTTPClient(testFetch),
})
expect(testFetch).toHaveBeenCalledTimes(0)
await helpers.makeTrackCall()
expect(testFetch).toHaveBeenCalledTimes(1)
helpers.assertFetchCallRequest(...testFetch.mock.lastCall)
})
it('can be a simple function that matches the fetch interface', async () => {
analytics = createTestAnalytics({ httpClient: testFetch })
expect(testFetch).toHaveBeenCalledTimes(0)
await helpers.makeTrackCall()
expect(testFetch).toHaveBeenCalledTimes(1)
helpers.assertFetchCallRequest(...testFetch.mock.lastCall)
})
})
13 changes: 8 additions & 5 deletions packages/node/src/__tests__/http-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe('Method Smoke Tests', () => {
let scope: nock.Scope
let ajs: Analytics
beforeEach(async () => {
ajs = createTestAnalytics()
ajs = createTestAnalytics({}, { useRealHTTPClient: true })
})

describe('Metadata', () => {
Expand Down Expand Up @@ -333,10 +333,13 @@ describe('Client: requestTimeout', () => {
})
it('should timeout immediately if request timeout is set to 0', async () => {
jest.useRealTimers()
const ajs = createTestAnalytics({
maxEventsInBatch: 1,
httpRequestTimeout: 0,
})
const ajs = createTestAnalytics(
{
maxEventsInBatch: 1,
httpRequestTimeout: 0,
},
{ useRealHTTPClient: true }
)
ajs.track({ event: 'foo', userId: 'foo', properties: { hello: 'world' } })
try {
await resolveCtx(ajs, 'track')
Expand Down
30 changes: 17 additions & 13 deletions packages/node/src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
const fetcher = jest.fn()
jest.mock('../lib/fetch', () => ({ fetch: fetcher }))

import { Plugin } from '../app/types'
import { resolveCtx } from './test-helpers/resolve-ctx'
import { testPlugin } from './test-helpers/test-plugin'
import { createSuccess, createError } from './test-helpers/factories'
import { createTestAnalytics } from './test-helpers/create-test-analytics'
import { createError } from './test-helpers/factories'
import {
createTestAnalytics,
TestFetchClient,
} from './test-helpers/create-test-analytics'

const writeKey = 'foo'
jest.setTimeout(10000)
const timestamp = new Date()

beforeEach(() => {
fetcher.mockReturnValue(createSuccess())
})
const testClient = new TestFetchClient()
const makeReqSpy = jest.spyOn(testClient, 'makeRequest')

describe('Settings / Configuration Init', () => {
it('throws if no writeKey', () => {
Expand All @@ -28,11 +27,12 @@ describe('Settings / Configuration Init', () => {
const analytics = createTestAnalytics({
host: 'http://foo.com',
path: '/bar',
httpClient: testClient,
})
const track = resolveCtx(analytics, 'track')
analytics.track({ event: 'foo', userId: 'sup' })
await track
expect(fetcher.mock.calls[0][0]).toBe('http://foo.com/bar')
expect(makeReqSpy.mock.calls[0][0].url).toBe('http://foo.com/bar')
})

it('throws if host / path is bad', async () => {
Expand All @@ -53,10 +53,14 @@ describe('Error handling', () => {
})

it('should emit on an error', async () => {
const analytics = createTestAnalytics({ maxRetries: 0 })
fetcher.mockReturnValue(
createError({ statusText: 'Service Unavailable', status: 503 })
)
const err = createError({
statusText: 'Service Unavailable',
status: 503,
})
const analytics = createTestAnalytics({
maxRetries: 0,
httpClient: new TestFetchClient({ response: err }),
})
try {
const promise = resolveCtx(analytics, 'track')
analytics.track({ event: 'foo', userId: 'sup' })
Expand Down
Loading