diff --git a/src/metrics.ts b/src/metrics.ts index 45d20996..9030ba52 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -11,6 +11,7 @@ import { SUPPORTED_SPEC_VERSION } from './repository'; export interface MetricsOptions { appName: string; instanceId: string; + connectionId: string; strategies: string[]; metricsInterval: number; metricsJitter?: number; @@ -80,6 +81,8 @@ export default class Metrics extends EventEmitter { private instanceId: string; + private connectionId: string; + private sdkVersion: string; private strategies: string[]; @@ -111,6 +114,7 @@ export default class Metrics extends EventEmitter { constructor({ appName, instanceId, + connectionId, strategies, metricsInterval = 0, metricsJitter = 0, @@ -127,6 +131,7 @@ export default class Metrics extends EventEmitter { this.metricsJitter = metricsJitter; this.appName = appName; this.instanceId = instanceId; + this.connectionId = connectionId; this.sdkVersion = sdkVersion; this.strategies = strategies; this.url = url; @@ -198,6 +203,7 @@ export default class Metrics extends EventEmitter { json: payload, appName: this.appName, instanceId: this.instanceId, + connectionId: this.connectionId, headers, timeout: this.timeout, httpOptions: this.httpOptions, @@ -250,6 +256,7 @@ export default class Metrics extends EventEmitter { json: payload, appName: this.appName, instanceId: this.instanceId, + connectionId: this.connectionId, headers, timeout: this.timeout, httpOptions: this.httpOptions, diff --git a/src/repository/bootstrap-provider.ts b/src/repository/bootstrap-provider.ts index 5918e0d1..354d8514 100644 --- a/src/repository/bootstrap-provider.ts +++ b/src/repository/bootstrap-provider.ts @@ -48,7 +48,13 @@ export class DefaultBootstrapProvider implements BootstrapProvider { const response = await fetch(bootstrapUrl, { method: 'GET', timeout: 10_000, - headers: buildHeaders(this.appName, this.instanceId, undefined, undefined, this.urlHeaders), + headers: buildHeaders({ + appName: this.appName, + instanceId: this.instanceId, + etag: undefined, + contentType: undefined, + custom: this.urlHeaders, + }), retry: { retries: 2, maxTimeout: 10_000, diff --git a/src/repository/index.ts b/src/repository/index.ts index 97571ad6..ff7d6f75 100644 --- a/src/repository/index.ts +++ b/src/repository/index.ts @@ -30,6 +30,7 @@ export interface RepositoryOptions { url: string; appName: string; instanceId: string; + connectionId: string; projectName?: string; refreshInterval: number; timeout?: number; @@ -59,6 +60,8 @@ export default class Repository extends EventEmitter implements EventEmitter { private instanceId: string; + private connectionId: string; + private refreshInterval: number; private headers?: CustomHeaders; @@ -101,6 +104,7 @@ export default class Repository extends EventEmitter implements EventEmitter { url, appName, instanceId, + connectionId, projectName, refreshInterval = 15_000, timeout, @@ -118,6 +122,7 @@ export default class Repository extends EventEmitter implements EventEmitter { this.url = url; this.refreshInterval = refreshInterval; this.instanceId = instanceId; + this.connectionId = connectionId; this.appName = appName; this.projectName = projectName; this.headers = headers; @@ -386,6 +391,7 @@ Message: ${err.message}`, appName: this.appName, timeout: this.timeout, instanceId: this.instanceId, + connectionId: this.connectionId, headers, httpOptions: this.httpOptions, supportedSpecVersion: SUPPORTED_SPEC_VERSION, diff --git a/src/request.ts b/src/request.ts index 15186ca5..1a607cb3 100644 --- a/src/request.ts +++ b/src/request.ts @@ -7,6 +7,7 @@ import { URL } from 'url'; import { getProxyForUrl } from 'proxy-from-env'; import { CustomHeaders } from './headers'; import { HttpOptions } from './http-options'; +const details = require('./details.json'); export interface RequestOptions { url: string; @@ -18,6 +19,7 @@ export interface GetRequestOptions extends RequestOptions { etag?: string; appName?: string; instanceId?: string; + connectionId: string; supportedSpecVersion?: string; httpOptions?: HttpOptions; } @@ -30,6 +32,7 @@ export interface PostRequestOptions extends RequestOptions { json: Data; appName?: string; instanceId?: string; + connectionId?: string; httpOptions?: HttpOptions; } @@ -55,18 +58,35 @@ export const getDefaultAgent = (url: URL) => { : new HttpProxyAgent(proxy, httpAgentOptions); }; -export const buildHeaders = ( - appName?: string, - instanceId?: string, - etag?: string, - contentType?: string, - custom?: CustomHeaders, - specVersionSupported?: string, -): Record => { +type HeaderOptions = { + appName?: string; + instanceId?: string; + etag?: string; + contentType?: string; + custom?: CustomHeaders; + specVersionSupported?: string; + connectionId?: string; +}; + +export const buildHeaders = ({ + appName, + instanceId, + etag, + contentType, + custom, + specVersionSupported, + connectionId, +}: HeaderOptions): Record => { const head: Record = {}; if (appName) { + // TODO: delete head['UNLEASH-APPNAME'] = appName; + // TODO: delete head['User-Agent'] = appName; + head['x-unleash-appname'] = appName; + } + if (connectionId) { + head['x-unleash-connection-id'] = connectionId; } if (instanceId) { head['UNLEASH-INSTANCEID'] = instanceId; @@ -80,6 +100,8 @@ export const buildHeaders = ( if (specVersionSupported) { head['Unleash-Client-Spec'] = specVersionSupported; } + const version = details.version; + head['x-unleash-sdk'] = `unleash-node@${version}`; if (custom) { Object.assign(head, custom); } @@ -91,6 +113,7 @@ export const post = ({ appName, timeout, instanceId, + connectionId, headers, json, httpOptions, @@ -99,7 +122,14 @@ export const post = ({ timeout: timeout || 10000, method: 'POST', agent: httpOptions?.agent || getDefaultAgent, - headers: buildHeaders(appName, instanceId, undefined, 'application/json', headers), + headers: buildHeaders({ + appName, + instanceId, + connectionId, + etag: undefined, + contentType: 'application/json', + custom: headers, + }), body: JSON.stringify(json), strictSSL: httpOptions?.rejectUnauthorized, }); @@ -110,6 +140,7 @@ export const get = ({ appName, timeout, instanceId, + connectionId, headers, httpOptions, supportedSpecVersion, @@ -118,7 +149,15 @@ export const get = ({ method: 'GET', timeout: timeout || 10_000, agent: httpOptions?.agent || getDefaultAgent, - headers: buildHeaders(appName, instanceId, etag, undefined, headers, supportedSpecVersion), + headers: buildHeaders({ + appName, + instanceId, + etag, + contentType: undefined, + custom: headers, + specVersionSupported: supportedSpecVersion, + connectionId, + }), retry: { retries: 2, maxTimeout: timeout || 10_000, diff --git a/src/test/metrics.test.ts b/src/test/metrics.test.ts index 7d4980ac..1b545aa4 100644 --- a/src/test/metrics.test.ts +++ b/src/test/metrics.test.ts @@ -122,17 +122,27 @@ test('should sendMetrics', async (t) => { t.true(metricsEP.isDone()); }); -test('should send custom headers', (t) => +test('should send correct custom and x-unleash headers', (t) => new Promise((resolve) => { const url = getUrl(); t.plan(2); const randomKey = `value-${Math.random()}`; - const metricsEP = nockMetrics(url).matchHeader('randomKey', randomKey); - const regEP = nockRegister(url).matchHeader('randomKey', randomKey); + const metricsEP = nockMetrics(url) + .matchHeader('randomKey', randomKey) + .matchHeader('x-unleash-appname', 'appName') + .matchHeader('x-unleash-sdk', /^unleash-node@(\d+\.\d+\.\d+)$/) + .matchHeader('x-unleash-connection-id', 'connectionId'); + const regEP = nockRegister(url) + .matchHeader('randomKey', randomKey) + .matchHeader('x-unleash-appname', 'appName') + .matchHeader('x-unleash-sdk', /^unleash-node@(\d+\.\d+\.\d+)$/) + .matchHeader('x-unleash-connection-id', 'connectionId'); // @ts-expect-error const metrics = new Metrics({ url, + appName: 'appName', + connectionId: 'connectionId', metricsInterval: 50, headers: { randomKey, @@ -259,6 +269,7 @@ test('sendMetrics should backoff on 404', async (t) => { const metrics = new Metrics({ appName: '404-tester', instanceId: '404-instance', + connectionId: '404-connection', metricsInterval: 10, strategies: [], url, @@ -449,6 +460,7 @@ test('sendMetrics should stop on 401', async (t) => { const metrics = new Metrics({ appName: '401-tester', instanceId: '401-instance', + connectionId: '401-connection', metricsInterval: 0, strategies: [], url, @@ -465,6 +477,7 @@ test('sendMetrics should stop on 403', async (t) => { const metrics = new Metrics({ appName: '401-tester', instanceId: '401-instance', + connectionId: '401-connection', metricsInterval: 0, strategies: [], url, @@ -481,6 +494,7 @@ test('sendMetrics should backoff on 429', async (t) => { const metrics = new Metrics({ appName: '429-tester', instanceId: '429-instance', + connectionId: '429-connection', metricsInterval: 10, strategies: [], url, @@ -504,6 +518,7 @@ test('sendMetrics should backoff on 500', async (t) => { const metrics = new Metrics({ appName: '500-tester', instanceId: '500-instance', + connectionId: '500-connetion', metricsInterval: 10, strategies: [], url, @@ -528,6 +543,7 @@ test('sendMetrics should backoff on 429 and gradually reduce interval', async (t const metrics = new Metrics({ appName: '429-tester', instanceId: '429-instance', + connectionId: '429-connection', metricsInterval, strategies: [], url, diff --git a/src/test/repository.test.ts b/src/test/repository.test.ts index 00a15623..7e31db0f 100644 --- a/src/test/repository.test.ts +++ b/src/test/repository.test.ts @@ -14,6 +14,7 @@ import { EventEmitter } from 'events'; const appName = 'foo'; const instanceId = 'bar'; +const connectionId = 'baz'; // @ts-expect-error function setup(url, toggles, headers = {}) { @@ -37,6 +38,7 @@ test('should fetch from endpoint', (t) => url, appName, instanceId, + connectionId, refreshInterval: 10, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -66,6 +68,7 @@ test('should poll for changes', (t) => url, appName, instanceId, + connectionId, refreshInterval: 10, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -98,6 +101,7 @@ test('should retry even if custom header function fails', (t) => url, appName, instanceId, + connectionId, refreshInterval: 10, customHeadersFunction: () => { throw new Error('custom function fails'); @@ -129,6 +133,7 @@ test('should store etag', (t) => url, appName, instanceId, + connectionId, refreshInterval: 10, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -158,6 +163,7 @@ test('should request with etag', (t) => url, appName, instanceId, + connectionId, refreshInterval: 10, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -177,12 +183,15 @@ test('should request with etag', (t) => repo.start(); })); -test('should request with custom headers', (t) => +test('should request with correct custom and x-unleash headers', (t) => new Promise((resolve) => { const url = 'http://unleash-test-4-x.app'; const randomKey = `random-${Math.random()}`; nock(url) .matchHeader('randomKey', randomKey) + .matchHeader('x-unleash-appname', appName) + .matchHeader('x-unleash-connection-id', connectionId) + .matchHeader('x-unleash-sdk', /^unleash-node@(\d+\.\d+\.\d+)$/) .persist() .get('/client/features') .reply(200, { features: [] }, { Etag: '12345-3' }); @@ -191,6 +200,7 @@ test('should request with custom headers', (t) => url, appName, instanceId, + connectionId, refreshInterval: 10, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -229,6 +239,7 @@ test('request with customHeadersFunction should take precedence over customHeade url, appName, instanceId, + connectionId, refreshInterval: 10, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -259,6 +270,7 @@ test('should handle 429 request error and emit warn event', async (t) => { url, appName, instanceId, + connectionId, refreshInterval: 10, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -291,6 +303,7 @@ test('should handle 401 request error and emit error event', (t) => url, appName, instanceId, + connectionId, refreshInterval: 10, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -317,6 +330,7 @@ test('should handle 403 request error and emit error event', (t) => url, appName, instanceId, + connectionId, refreshInterval: 10, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -343,6 +357,7 @@ test('should handle 500 request error and emit warn event', (t) => url, appName, instanceId, + connectionId, refreshInterval: 10, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -363,6 +378,7 @@ test.skip('should handle 502 request error and emit warn event', (t) => url, appName, instanceId, + connectionId, refreshInterval: 10, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -383,6 +399,7 @@ test.skip('should handle 503 request error and emit warn event', (t) => url, appName, instanceId, + connectionId, refreshInterval: 10, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -403,6 +420,7 @@ test.skip('should handle 504 request error and emit warn event', (t) => url, appName, instanceId, + connectionId, refreshInterval: 10, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -427,6 +445,7 @@ test('should handle 304 as silent ok', (t) => { url, appName, instanceId, + connectionId, refreshInterval: 0, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -449,6 +468,7 @@ test('should handle invalid JSON response', (t) => url, appName, instanceId, + connectionId, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), storageProvider: new InMemStorageProvider(), @@ -508,6 +528,7 @@ test('should emit errors on invalid features', (t) => url, appName, instanceId, + connectionId, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), storageProvider: new InMemStorageProvider(), @@ -541,6 +562,7 @@ test('should emit errors on invalid variant', (t) => url, appName, instanceId, + connectionId, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), storageProvider: new InMemStorageProvider(), @@ -603,6 +625,7 @@ test('should load bootstrap first if faster than unleash-api', (t) => url, appName, instanceId, + connectionId, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({ url: bootstrap }), storageProvider: new InMemStorageProvider(), @@ -669,6 +692,7 @@ test('bootstrap should not override actual data', (t) => url, appName, instanceId, + connectionId, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({ url: bootstrap }), storageProvider: new InMemStorageProvider(), @@ -717,6 +741,7 @@ test('should load bootstrap first from file', (t) => url, appName, instanceId, + connectionId, refreshInterval: 0, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({ filePath: path }), @@ -743,6 +768,7 @@ test('should not crash on bogus bootstrap', (t) => url, appName, instanceId, + connectionId, refreshInterval: 0, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({ filePath: path }), @@ -788,6 +814,7 @@ test('should load backup-file', (t) => url, appName: appNameLocal, instanceId, + connectionId, refreshInterval: 0, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -834,6 +861,7 @@ test('bootstrap should override load backup-file', (t) => url, appName: appNameLocal, instanceId, + connectionId, refreshInterval: 0, // @ts-expect-error disableFetch: true, @@ -914,6 +942,7 @@ test('bootstrap should not override load backup-file', async (t) => { url, appName: appNameLocal, instanceId, + connectionId, refreshInterval: 0, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({ @@ -942,6 +971,7 @@ test.skip('Failing two times and then succeed should decrease interval to 2 time url, appName, instanceId, + connectionId, refreshInterval: 10, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -990,6 +1020,7 @@ test.skip('Failing two times should increase interval to 3 times initial interva url, appName, instanceId, + connectionId, refreshInterval: 10, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -1013,6 +1044,7 @@ test.skip('Failing two times and then succeed should decrease interval to 2 time url, appName, instanceId, + connectionId, refreshInterval: 10, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -1128,6 +1160,7 @@ test('should handle not finding a given segment id', (t) => url, appName: appNameLocal, instanceId, + connectionId, refreshInterval: 0, // @ts-expect-error disableFetch: true, @@ -1189,6 +1222,7 @@ test('should handle not having segments to read from', (t) => url, appName: appNameLocal, instanceId, + connectionId, refreshInterval: 0, // @ts-expect-error disableFetch: true, @@ -1284,6 +1318,7 @@ test('should return full segment data when requested', (t) => url, appName: appNameLocal, instanceId, + connectionId, refreshInterval: 0, // @ts-expect-error disableFetch: true, @@ -1316,6 +1351,7 @@ test('Stopping repository should stop unchanged event reporting', async (t) => { url, appName, instanceId, + connectionId, refreshInterval: 10, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -1348,6 +1384,7 @@ test('Stopping repository should stop storage provider updates', async (t) => { url, appName, instanceId, + connectionId, refreshInterval: 10, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), @@ -1396,6 +1433,7 @@ test('Streaming', async (t) => { url, appName, instanceId, + connectionId, refreshInterval: 10, // @ts-expect-error bootstrapProvider: new DefaultBootstrapProvider({}), diff --git a/src/test/request.test.ts b/src/test/request.test.ts index 5425b7a6..f131aa1d 100644 --- a/src/test/request.test.ts +++ b/src/test/request.test.ts @@ -14,9 +14,20 @@ test('https URLs should yield https.Agent', (t) => { t.true(agent instanceof https.Agent); }); -test('Custom headers should be included', (t) => { - const headers = buildHeaders('https://bullshit.com', undefined, undefined, undefined, { - hello: 'world', +test('Correct headers should be included', (t) => { + const headers = buildHeaders({ + appName: 'myApp', + instanceId: 'instanceId', + etag: undefined, + contentType: undefined, + connectionId: 'connectionId', + custom: { + hello: 'world', + }, }); t.is(headers.hello, 'world'); + t.is(headers['UNLEASH-INSTANCEID'], 'instanceId'); + t.is(headers['x-unleash-connection-id'], 'connectionId'); + t.is(headers['x-unleash-appname'], 'myApp'); + t.regex(headers['x-unleash-sdk'], /^unleash-node@(\d+\.\d+\.\d+)$/); }); diff --git a/src/test/unleash.test.ts b/src/test/unleash.test.ts index 2579cf8b..6f671e96 100644 --- a/src/test/unleash.test.ts +++ b/src/test/unleash.test.ts @@ -51,7 +51,10 @@ const defaultToggles = [ ]; function mockNetwork(toggles = defaultToggles, url = getUrl()) { - nock(url).get('/client/features').reply(200, { features: toggles }); + nock(url) + .get('/client/features') + .matchHeader('x-unleash-connection-id', /^.{36}$/) + .reply(200, { features: toggles }); return url; } diff --git a/src/unleash.ts b/src/unleash.ts index 81c24907..588a06a8 100644 --- a/src/unleash.ts +++ b/src/unleash.ts @@ -22,6 +22,7 @@ import { resolveUrl } from './url-utils'; // @ts-expect-error import { EventSource } from 'launchdarkly-eventsource'; import { buildHeaders } from './request'; +import { randomUUID } from 'crypto'; export { Strategy, UnleashEvents, UnleashConfig }; const BACKUP_PATH: string = tmpdir(); @@ -107,6 +108,8 @@ export class Unleash extends EventEmitter { const unleashInstanceId = generateInstanceId(instanceId); + const unleashConnectionId = randomUUID(); + this.staticContext = { appName, environment }; const bootstrapProvider = resolveBootstrapProvider(bootstrap, appName, unleashInstanceId); @@ -118,6 +121,7 @@ export class Unleash extends EventEmitter { url: unleashUrl, appName, instanceId: unleashInstanceId, + connectionId: unleashConnectionId, refreshInterval, headers: customHeaders, customHeadersFunction, @@ -130,14 +134,15 @@ export class Unleash extends EventEmitter { eventSource: experimentalMode?.type === 'streaming' ? new EventSource(resolveUrl(unleashUrl, './client/streaming'), { - headers: buildHeaders( + headers: buildHeaders({ appName, - instanceId, - undefined, - undefined, - customHeaders, - SUPPORTED_SPEC_VERSION, - ), + instanceId: unleashInstanceId, + etag: undefined, + contentType: undefined, + custom: customHeaders, + specVersionSupported: SUPPORTED_SPEC_VERSION, + connectionId: unleashConnectionId, + }), readTimeoutMillis: 60000, // start a new SSE connection when no heartbeat received in 1 minute initialRetryDelayMillis: 2000, maxBackoffMillis: 30000, @@ -191,6 +196,7 @@ export class Unleash extends EventEmitter { disableMetrics, appName, instanceId: unleashInstanceId, + connectionId: unleashConnectionId, strategies: supportedStrategies.map((strategy: Strategy) => strategy.name), metricsInterval, metricsJitter,