From 63976cdd7a744d67c41761750f7ff662ff80c160 Mon Sep 17 00:00:00 2001 From: LaunchDarklyReleaseBot <86431345+LaunchDarklyReleaseBot@users.noreply.github.com> Date: Thu, 21 Apr 2022 13:49:43 -0700 Subject: [PATCH] prepare 4.1.0 release (#67) * initial move of code from js-client-sdk-private * changelog note * rm obsolete comment * add npm audit helper * update babel, jest, rollup * fix rollup config * fix ES build, dependency cleanup * add Releaser metadata * Update babel config to work in `test` without `useBuiltIns` * copyedits * fix misnamed directory * use spread operator instead of Object.assign * add issue templates * add babel-eslint * add event capacity config property * re-add deprecation warning on samplingInterval * better config validation * remove rollup-plugins-node-resolve * use newer Rollup node-resolve plugin * rm rollup-plugin-includepaths (unused) * npm audit fix (handlebars dependency from jest) * comment * copyedit * use new test helpers + misc test cleanup * clean up stream testing logic * fix hash parameter * linter * clearer way to model the config option defaults/types * test improvements * change internal param name * comment * fix default logger logic * simpler way to enforce minimum values * implement diagnostic events in common JS package (#11) * add support for function type in config options * add support for function type in config options (#13) * add wrapper metadata options and fix custom header logic * lint * lint * remove image-loading logic from common code, replace it with an abstraction * add validation for options.streaming * typo * rm unused params * typo in comment * misc fixes to merged code from external PR * add event payload ID header * npm audit fix * change exact dependencies to best-compatible * standardize linting * disallow "window" and "document" * improve diag event tests + debug logging * misc cleanup * fix updating secure mode hash with identify() * don't omit streamInits.failed when it's false * clean up init state logic, prevent unhandled rejections * lint * less strict matching of json content-type header * remove unsafe usage of hasOwnProperty * console logger must tolerate console object not always existing * fix double initialization of diagnostics manager * fix TypeScript declaration for track() and add more TS compilation tests (#27) * remove startsWith usage (#28) * prevent poll caused by a stream ping from overwriting later poll for another user (#29) * upgrade jest dependency and transitive yargs-parser dependency (#30) * Add null to LDEvaluationDetail.reason type (#31) * Revert "Add null to LDEvaluationDetail.reason type (#31)" This reverts commit bcb1573b596a3f73591e3f294b37dd119ae0e766. * Revert "Add null to LDEvaluationDetail.reason type (#31)" This reverts commit bcb1573b596a3f73591e3f294b37dd119ae0e766. * nullable evaluation reason (#32) * adding alias event functionality (#33) * set stream read timeout * Add prepare script (#34) * add a missing typescript verification (#36) * Removed the guides link * Correct doc link (#36) * Fix typo in LDClient.on jsdoc (#37) * add inlineUsersInEvents option in TypeScript (#37) * Filter private attributes on debug event users. Send variation for debug events. * update uuid package version (#39) * use Releaser v2 config + newer CI image * First half, add the type, create the new options, add the new util method, and add tests * Second half, call the tranform util method before calling any HTTP requests * Update the transform to work on a copy of headers instead of mutating it * add comments about removing custom event warning logic in the future * revert updating of UUID dependency (#43) * Revert "update uuid package version (#39)" This reverts commit 3b2ff6c7dcfa008b947c1d49b6bbcc0e13963b1b. * update package-lock.json * better error handling for local storage operations (#44) * better error handling for local storage operations * lint * fix obsolete comments * add basic logger similar to server-side Node SDK (#45) * fix exports and add validation of custom logger (#46) * remove typedoc.js file that interferes with Releaser's docs build * update typescript version * add maintenance branch * remove deprecated things (#48) * remove deprecated options and function * rm references to obsolete function * restore deprecation logic, just leave the data empty * remove samplingInterval from TS test code * fix TS test code again * fix EvaluationDetail.reason to be nullable so we can get rid of NonNullableLDEvaluationReason type (#49) * remove deprecated options and function * rm references to obsolete function * restore deprecation logic, just leave the data empty * remove samplingInterval from TS test code * fix TS test code again * fix EvaluationDetail.reason to be nullable so we can get rid of NonNullableLDEvaluationReason type * fix TS test code * re-bump uuid package (#50) * Revert "Revert "update uuid package version (#39)"" This reverts commit 89359b1bf4ddbe6b2fedb95f1dc11240483c60f7. * remove lockfile (sc-107301) * use regular User-Agent header name unless overridden by js-client-sdk (#52) * switch to publishing js-sdk-common as a regular Node module (#51) * fix CI * remove `version` constant which can't be exported from js-sdk-common (#53) * catch errors in JSON parsing of stream data (#54) * catch errors in JSON parsing of stream data * lint * backport sc-142333 fix * prepare 3.5.1 release (#63) * initial move of code from js-client-sdk-private * changelog note * rm obsolete comment * add npm audit helper * update babel, jest, rollup * fix rollup config * fix ES build, dependency cleanup * add Releaser metadata * Update babel config to work in `test` without `useBuiltIns` * copyedits * fix misnamed directory * use spread operator instead of Object.assign * add issue templates * add babel-eslint * add event capacity config property * re-add deprecation warning on samplingInterval * better config validation * remove rollup-plugins-node-resolve * use newer Rollup node-resolve plugin * rm rollup-plugin-includepaths (unused) * npm audit fix (handlebars dependency from jest) * comment * copyedit * use new test helpers + misc test cleanup * clean up stream testing logic * fix hash parameter * linter * clearer way to model the config option defaults/types * test improvements * change internal param name * comment * fix default logger logic * simpler way to enforce minimum values * implement diagnostic events in common JS package (#11) * add support for function type in config options * add support for function type in config options (#13) * add wrapper metadata options and fix custom header logic * lint * lint * remove image-loading logic from common code, replace it with an abstraction * add validation for options.streaming * typo * rm unused params * typo in comment * misc fixes to merged code from external PR * add event payload ID header * npm audit fix * change exact dependencies to best-compatible * standardize linting * disallow "window" and "document" * improve diag event tests + debug logging * misc cleanup * fix updating secure mode hash with identify() * don't omit streamInits.failed when it's false * clean up init state logic, prevent unhandled rejections * lint * less strict matching of json content-type header * remove unsafe usage of hasOwnProperty * console logger must tolerate console object not always existing * fix double initialization of diagnostics manager * fix TypeScript declaration for track() and add more TS compilation tests (#27) * remove startsWith usage (#28) * prevent poll caused by a stream ping from overwriting later poll for another user (#29) * upgrade jest dependency and transitive yargs-parser dependency (#30) * Add null to LDEvaluationDetail.reason type (#31) * Revert "Add null to LDEvaluationDetail.reason type (#31)" This reverts commit bcb1573b596a3f73591e3f294b37dd119ae0e766. * Revert "Add null to LDEvaluationDetail.reason type (#31)" This reverts commit bcb1573b596a3f73591e3f294b37dd119ae0e766. * nullable evaluation reason (#32) * adding alias event functionality (#33) * set stream read timeout * Add prepare script (#34) * add a missing typescript verification (#36) * Removed the guides link * Correct doc link (#36) * Fix typo in LDClient.on jsdoc (#37) * add inlineUsersInEvents option in TypeScript (#37) * Filter private attributes on debug event users. Send variation for debug events. * update uuid package version (#39) * use Releaser v2 config + newer CI image * First half, add the type, create the new options, add the new util method, and add tests * Second half, call the tranform util method before calling any HTTP requests * Update the transform to work on a copy of headers instead of mutating it * add comments about removing custom event warning logic in the future * revert updating of UUID dependency (#43) * Revert "update uuid package version (#39)" This reverts commit 3b2ff6c7dcfa008b947c1d49b6bbcc0e13963b1b. * update package-lock.json * better error handling for local storage operations (#44) * better error handling for local storage operations * lint * fix obsolete comments * add basic logger similar to server-side Node SDK (#45) * fix exports and add validation of custom logger (#46) * remove typedoc.js file that interferes with Releaser's docs build * update typescript version * add maintenance branch * backport sc-142333 fix Co-authored-by: Eli Bishop Co-authored-by: Zach Davis Co-authored-by: LaunchDarklyCI Co-authored-by: Ben Woskow Co-authored-by: Ben Woskow <48036130+bwoskow-ld@users.noreply.github.com> Co-authored-by: Michael Siadak Co-authored-by: Jeff Wen Co-authored-by: Andrey Krasnov <34657799+Doesntmeananything@users.noreply.github.com> Co-authored-by: Gavin Whelan Co-authored-by: LaunchDarklyReleaseBot Co-authored-by: Louis Chan Co-authored-by: Louis Chan <91093020+louis-launchdarkly@users.noreply.github.com> * Releasing version 3.5.1 * rm obsolete file to fix merge * Releasing version 3.5.1 * make URL path concatenation work right whether base URL has a trailing slash or not (#61) * make URL path concatenation work right whether base URL has a trailing slash or not * lint * Implement support for application tags. (#55) * Fix typing of LDOptionsBase. (#63) * Implement application tags for 3.x. (#62) * lint Co-authored-by: Eli Bishop Co-authored-by: Zach Davis Co-authored-by: LaunchDarklyCI Co-authored-by: Ben Woskow Co-authored-by: Ben Woskow <48036130+bwoskow-ld@users.noreply.github.com> Co-authored-by: Michael Siadak Co-authored-by: Jeff Wen Co-authored-by: Andrey Krasnov <34657799+Doesntmeananything@users.noreply.github.com> Co-authored-by: Gavin Whelan Co-authored-by: LaunchDarklyReleaseBot Co-authored-by: Louis Chan Co-authored-by: Louis Chan <91093020+louis-launchdarkly@users.noreply.github.com> Co-authored-by: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> --- CHANGELOG.md | 4 + src/EventProcessor.js | 2 +- src/EventSender.js | 5 +- src/Requestor.js | 7 +- src/Stream.js | 7 +- src/__tests__/Stream-test.js | 2 +- src/__tests__/configuration-test.js | 39 ++++++++++ src/__tests__/headers-test.js | 117 ++++++++++++++++++++++++++++ src/__tests__/utils-test.js | 81 ++----------------- src/configuration.js | 63 ++++++++++++++- src/diagnosticEvents.js | 3 +- src/headers.js | 38 +++++++++ src/messages.js | 3 + src/utils.js | 31 ++------ test-types.ts | 6 +- typings.d.ts | 37 ++++++++- 16 files changed, 332 insertions(+), 113 deletions(-) create mode 100644 src/__tests__/headers-test.js create mode 100644 src/headers.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 80ee40f..cb5dc58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ All notable changes to the `launchdarkly-js-sdk-common` package will be document - Removed the type `NonNullableLDEvaluationReason`, which was a side effect of the `LDEvaluationDetail.reason` being incorrectly defined before. - Removed all types, properties, and functions that were deprecated as of the last 3.x release. +## [3.5.1] - 2022-02-17 +### Fixed: +- If the SDK receives invalid JSON data from a streaming connection (possibly as a result of the connection being cut off), it now uses its regular error-handling logic: the error is emitted as an `error` event or, if there are no `error` event listeners, it is logged. Previously, it would be thrown as an unhandled exception. + ## [3.5.0] - 2022-01-14 ### Added: - New configurable logger factory `commonBasicLogger` and `BasicLoggerOptions`. The `commonBasicLogger` method is not intended to be exported directly in the SDKs, but wrapped to provide platform-specific behavior. diff --git a/src/EventProcessor.js b/src/EventProcessor.js index 6d0cd75..c5d0946 100644 --- a/src/EventProcessor.js +++ b/src/EventProcessor.js @@ -15,7 +15,7 @@ function EventProcessor( ) { const processor = {}; const eventSender = sender || EventSender(platform, environmentId, options); - const mainEventsUrl = options.eventsUrl + '/events/bulk/' + environmentId; + const mainEventsUrl = utils.appendUrlPath(options.eventsUrl, '/events/bulk/' + environmentId); const summarizer = EventSummarizer(); const userFilter = UserFilter(options); const inlineUsers = options.inlineUsersInEvents; diff --git a/src/EventSender.js b/src/EventSender.js index b3a9455..01f117a 100644 --- a/src/EventSender.js +++ b/src/EventSender.js @@ -1,12 +1,13 @@ const errors = require('./errors'); const utils = require('./utils'); const { v1: uuidv1 } = require('uuid'); +const { getLDHeaders, transformHeaders } = require('./headers'); const MAX_URL_LENGTH = 2000; function EventSender(platform, environmentId, options) { const imageUrlPath = '/a/' + environmentId + '.gif'; - const baseHeaders = utils.extend({ 'Content-Type': 'application/json' }, utils.getLDHeaders(platform, options)); + const baseHeaders = utils.extend({ 'Content-Type': 'application/json' }, getLDHeaders(platform, options)); const httpFallbackPing = platform.httpFallbackPing; // this will be set for us if we're in the browser SDK const sender = {}; @@ -34,7 +35,7 @@ function EventSender(platform, environmentId, options) { 'X-LaunchDarkly-Payload-ID': payloadId, }); return platform - .httpRequest('POST', url, utils.transformHeaders(headers, options), jsonBody) + .httpRequest('POST', url, transformHeaders(headers, options), jsonBody) .promise.then(result => { if (!result) { // This was a response from a fire-and-forget request, so we won't have a status. diff --git a/src/Requestor.js b/src/Requestor.js index e542f39..b8beb40 100644 --- a/src/Requestor.js +++ b/src/Requestor.js @@ -2,6 +2,7 @@ const utils = require('./utils'); const errors = require('./errors'); const messages = require('./messages'); const promiseCoalescer = require('./promiseCoalescer'); +const { transformHeaders, getLDHeaders } = require('./headers'); const jsonContentType = 'application/json'; @@ -31,7 +32,7 @@ function Requestor(platform, options, environment) { } const method = body ? 'REPORT' : 'GET'; - const headers = utils.getLDHeaders(platform, options); + const headers = getLDHeaders(platform, options); if (body) { headers['Content-Type'] = jsonContentType; } @@ -45,7 +46,7 @@ function Requestor(platform, options, environment) { activeRequests[endpoint] = coalescer; } - const req = platform.httpRequest(method, endpoint, utils.transformHeaders(headers, options), body); + const req = platform.httpRequest(method, endpoint, transformHeaders(headers, options), body); const p = req.promise.then( result => { if (result.status === 200) { @@ -75,7 +76,7 @@ function Requestor(platform, options, environment) { // Performs a GET request to an arbitrary path under baseUrl. Returns a Promise which will resolve // with the parsed JSON response, or will be rejected if the request failed. requestor.fetchJSON = function(path) { - return fetchJSON(baseUrl + path, null); + return fetchJSON(utils.appendUrlPath(baseUrl, path), null); }; // Requests the current state of all flags for the given user from LaunchDarkly. Returns a Promise diff --git a/src/Stream.js b/src/Stream.js index 433d089..b4c1af4 100644 --- a/src/Stream.js +++ b/src/Stream.js @@ -1,5 +1,6 @@ const messages = require('./messages'); -const { base64URLEncode, getLDHeaders, transformHeaders, objectHasOwnProperty } = require('./utils'); +const { appendUrlPath, base64URLEncode, objectHasOwnProperty } = require('./utils'); +const { getLDHeaders, transformHeaders } = require('./headers'); // The underlying event source implementation is abstracted via the platform object, which should // have these three properties: @@ -20,7 +21,7 @@ function Stream(platform, config, environment, diagnosticsAccumulator) { const baseUrl = config.streamUrl; const logger = config.logger; const stream = {}; - const evalUrlPrefix = baseUrl + '/eval/' + environment; + const evalUrlPrefix = appendUrlPath(baseUrl, '/eval/' + environment); const useReport = config.useReport; const withReasons = config.evaluationReasons; const streamReconnectDelay = config.streamReconnectDelay; @@ -98,7 +99,7 @@ function Stream(platform, config, environment, diagnosticsAccumulator) { options.body = JSON.stringify(user); } else { // if we can't do REPORT, fall back to the old ping-based stream - url = baseUrl + '/ping/' + environment; + url = appendUrlPath(baseUrl, '/ping/' + environment); query = ''; } } else { diff --git a/src/__tests__/Stream-test.js b/src/__tests__/Stream-test.js index a392d7b..45e3f7a 100644 --- a/src/__tests__/Stream-test.js +++ b/src/__tests__/Stream-test.js @@ -1,7 +1,7 @@ import { DiagnosticsAccumulator } from '../diagnosticEvents'; import * as messages from '../messages'; import Stream from '../Stream'; -import { getLDHeaders } from '../utils'; +import { getLDHeaders } from '../headers'; import { sleepAsync } from 'launchdarkly-js-test-helpers'; import EventSource from './EventSource-mock'; diff --git a/src/__tests__/configuration-test.js b/src/__tests__/configuration-test.js index 0f0bcc3..1ef2e15 100644 --- a/src/__tests__/configuration-test.js +++ b/src/__tests__/configuration-test.js @@ -214,4 +214,43 @@ describe('configuration', () => { expect(config.extraFunctionOption).toBe(fn); await listener.expectError(messages.wrongOptionType('extraNumericOptionWithoutDefault', 'number', 'string')); }); + + it('handles a valid application id', async () => { + const listener = errorListener(); + const configIn = { application: { id: 'test-application' } }; + expect(configuration.validate(configIn, listener.emitter, null, listener.logger).application.id).toEqual( + 'test-application' + ); + }); + + it('logs a warning with an invalid application id', async () => { + const listener = errorListener(); + const configIn = { application: { id: 'test #$#$#' } }; + expect(configuration.validate(configIn, listener.emitter, null, listener.logger).application.id).toBeUndefined(); + await listener.expectWarningOnly(messages.invalidTagValue('application.id')); + }); + + it('handles a valid application version', async () => { + const listener = errorListener(); + const configIn = { application: { version: 'test-version' } }; + expect(configuration.validate(configIn, listener.emitter, null, listener.logger).application.version).toEqual( + 'test-version' + ); + }); + + it('logs a warning with an invalid application version', async () => { + const listener = errorListener(); + const configIn = { application: { version: 'test #$#$#' } }; + expect( + configuration.validate(configIn, listener.emitter, null, listener.logger).application.version + ).toBeUndefined(); + await listener.expectWarningOnly(messages.invalidTagValue('application.version')); + }); + + it('includes application id and version in tags when present', async () => { + expect(configuration.getTags({ application: { id: 'test-id', version: 'test-version' } })).toEqual({ + 'application-id': ['test-id'], + 'application-version': ['test-version'], + }); + }); }); diff --git a/src/__tests__/headers-test.js b/src/__tests__/headers-test.js new file mode 100644 index 0000000..2c6bacd --- /dev/null +++ b/src/__tests__/headers-test.js @@ -0,0 +1,117 @@ +import { getLDHeaders, transformHeaders } from '../headers'; +import { getLDUserAgentString } from '../utils'; +import * as stubPlatform from './stubPlatform'; + +describe('getLDHeaders', () => { + it('sends no headers unless sendLDHeaders is true', () => { + const platform = stubPlatform.defaults(); + const headers = getLDHeaders(platform, {}); + expect(headers).toEqual({}); + }); + + it('adds user-agent header', () => { + const platform = stubPlatform.defaults(); + const headers = getLDHeaders(platform, { sendLDHeaders: true }); + expect(headers).toMatchObject({ 'User-Agent': getLDUserAgentString(platform) }); + }); + + it('adds user-agent header with custom name', () => { + const platform = stubPlatform.defaults(); + platform.userAgentHeaderName = 'X-Fake-User-Agent'; + const headers = getLDHeaders(platform, { sendLDHeaders: true }); + expect(headers).toMatchObject({ 'X-Fake-User-Agent': getLDUserAgentString(platform) }); + }); + + it('adds wrapper info if specified, without version', () => { + const platform = stubPlatform.defaults(); + const headers = getLDHeaders(platform, { sendLDHeaders: true, wrapperName: 'FakeSDK' }); + expect(headers).toMatchObject({ + 'User-Agent': getLDUserAgentString(platform), + 'X-LaunchDarkly-Wrapper': 'FakeSDK', + }); + }); + + it('adds wrapper info if specified, with version', () => { + const platform = stubPlatform.defaults(); + const headers = getLDHeaders(platform, { sendLDHeaders: true, wrapperName: 'FakeSDK', wrapperVersion: '9.9' }); + expect(headers).toMatchObject({ + 'User-Agent': getLDUserAgentString(platform), + 'X-LaunchDarkly-Wrapper': 'FakeSDK/9.9', + }); + }); + + it('sets the X-LaunchDarkly-Tags header with valid id and version.', () => { + const platform = stubPlatform.defaults(); + const headers = getLDHeaders(platform, { + sendLDHeaders: true, + application: { + id: 'test-application', + version: 'test-version', + }, + }); + expect(headers).toMatchObject({ + 'User-Agent': getLDUserAgentString(platform), + 'x-launchdarkly-tags': 'application-id/test-application application-version/test-version', + }); + }); + + it('sets the X-LaunchDarkly-Tags header with just application id', () => { + const platform = stubPlatform.defaults(); + const headers = getLDHeaders(platform, { + sendLDHeaders: true, + application: { + id: 'test-application', + }, + }); + expect(headers).toMatchObject({ + 'User-Agent': getLDUserAgentString(platform), + 'x-launchdarkly-tags': 'application-id/test-application', + }); + }); + + it('sets the X-LaunchDarkly-Tags header with just application version.', () => { + const platform = stubPlatform.defaults(); + const headers = getLDHeaders(platform, { + sendLDHeaders: true, + application: { + version: 'test-version', + }, + }); + expect(headers).toMatchObject({ + 'User-Agent': getLDUserAgentString(platform), + 'x-launchdarkly-tags': 'application-version/test-version', + }); + }); +}); + +describe('transformHeaders', () => { + it('does not modify the headers if the option is not available', () => { + const inputHeaders = { a: '1', b: '2' }; + const headers = transformHeaders(inputHeaders, {}); + expect(headers).toEqual(inputHeaders); + }); + + it('modifies the headers if the option has a transform', () => { + const inputHeaders = { c: '3', d: '4' }; + const outputHeaders = { c: '9', d: '4', e: '5' }; + const headerTransform = input => { + const output = { ...input }; + output['c'] = '9'; + output['e'] = '5'; + return output; + }; + const headers = transformHeaders(inputHeaders, { requestHeaderTransform: headerTransform }); + expect(headers).toEqual(outputHeaders); + }); + + it('cannot mutate the input header object', () => { + const inputHeaders = { f: '6' }; + const expectedInputHeaders = { f: '6' }; + const headerMutate = input => { + input['f'] = '7'; // eslint-disable-line no-param-reassign + return input; + }; + transformHeaders(inputHeaders, { requestHeaderTransform: headerMutate }); + expect(inputHeaders).toEqual(expectedInputHeaders); + }); +}); diff --git a/src/__tests__/utils-test.js b/src/__tests__/utils-test.js index 250b8b9..2429767 100644 --- a/src/__tests__/utils-test.js +++ b/src/__tests__/utils-test.js @@ -1,7 +1,6 @@ import { + appendUrlPath, base64URLEncode, - getLDHeaders, - transformHeaders, getLDUserAgentString, wrapPromiseCallback, chunkUserEventsForUrl, @@ -10,6 +9,13 @@ import { import * as stubPlatform from './stubPlatform'; describe('utils', () => { + it('appendUrlPath', () => { + expect(appendUrlPath('http://base', '/path')).toEqual('http://base/path'); + expect(appendUrlPath('http://base', 'path')).toEqual('http://base/path'); + expect(appendUrlPath('http://base/', '/path')).toEqual('http://base/path'); + expect(appendUrlPath('http://base/', '/path')).toEqual('http://base/path'); + }); + describe('wrapPromiseCallback', () => { it('should resolve to the value', done => { const promise = wrapPromiseCallback(Promise.resolve('woohoo')); @@ -48,77 +54,6 @@ describe('utils', () => { }); }); - describe('getLDHeaders', () => { - it('sends no headers unless sendLDHeaders is true', () => { - const platform = stubPlatform.defaults(); - const headers = getLDHeaders(platform, {}); - expect(headers).toEqual({}); - }); - - it('adds user-agent header', () => { - const platform = stubPlatform.defaults(); - const headers = getLDHeaders(platform, { sendLDHeaders: true }); - expect(headers).toMatchObject({ 'User-Agent': getLDUserAgentString(platform) }); - }); - - it('adds user-agent header with custom name', () => { - const platform = stubPlatform.defaults(); - platform.userAgentHeaderName = 'X-Fake-User-Agent'; - const headers = getLDHeaders(platform, { sendLDHeaders: true }); - expect(headers).toMatchObject({ 'X-Fake-User-Agent': getLDUserAgentString(platform) }); - }); - - it('adds wrapper info if specified, without version', () => { - const platform = stubPlatform.defaults(); - const headers = getLDHeaders(platform, { sendLDHeaders: true, wrapperName: 'FakeSDK' }); - expect(headers).toMatchObject({ - 'User-Agent': getLDUserAgentString(platform), - 'X-LaunchDarkly-Wrapper': 'FakeSDK', - }); - }); - - it('adds wrapper info if specified, with version', () => { - const platform = stubPlatform.defaults(); - const headers = getLDHeaders(platform, { sendLDHeaders: true, wrapperName: 'FakeSDK', wrapperVersion: '9.9' }); - expect(headers).toMatchObject({ - 'User-Agent': getLDUserAgentString(platform), - 'X-LaunchDarkly-Wrapper': 'FakeSDK/9.9', - }); - }); - }); - - describe('transformHeaders', () => { - it('does not modify the headers if the option is not available', () => { - const inputHeaders = { a: '1', b: '2' }; - const headers = transformHeaders(inputHeaders, {}); - expect(headers).toEqual(inputHeaders); - }); - - it('modifies the headers if the option has a transform', () => { - const inputHeaders = { c: '3', d: '4' }; - const outputHeaders = { c: '9', d: '4', e: '5' }; - const headerTransform = input => { - const output = { ...input }; - output['c'] = '9'; - output['e'] = '5'; - return output; - }; - const headers = transformHeaders(inputHeaders, { requestHeaderTransform: headerTransform }); - expect(headers).toEqual(outputHeaders); - }); - - it('cannot mutate the input header object', () => { - const inputHeaders = { f: '6' }; - const expectedInputHeaders = { f: '6' }; - const headerMutate = input => { - input['f'] = '7'; // eslint-disable-line no-param-reassign - return input; - }; - transformHeaders(inputHeaders, { requestHeaderTransform: headerMutate }); - expect(inputHeaders).toEqual(expectedInputHeaders); - }); - }); - describe('getLDUserAgentString', () => { it('uses platform user-agent and unknown version by default', () => { const platform = stubPlatform.defaults(); diff --git a/src/configuration.js b/src/configuration.js index c76c8cd..50a1674 100644 --- a/src/configuration.js +++ b/src/configuration.js @@ -38,8 +38,38 @@ const baseOptionDefs = { wrapperVersion: { type: 'string' }, stateProvider: { type: 'object' }, // not a public option, used internally autoAliasingOptOut: { default: false }, + application: { validator: applicationConfigValidator }, }; +/** + * Expression to validate characters that are allowed in tag keys and values. + */ +const allowedTagCharacters = /^(\w|\.|-)+$/; + +/** + * Verify that a value meets the requirements for a tag value. + * @param {Object} config + * @param {string} tagValue + */ +function validateTagValue(name, config, tagValue, logger) { + if (typeof tagValue !== 'string' || !tagValue.match(allowedTagCharacters)) { + logger.warn(messages.invalidTagValue(name)); + return undefined; + } + return tagValue; +} + +function applicationConfigValidator(name, config, value, logger) { + const validated = {}; + if (value.id) { + validated.id = validateTagValue(`${name}.id`, config, value.id, logger); + } + if (value.version) { + validated.version = validateTagValue(`${name}.version`, config, value.version, logger); + } + return validated; +} + function validate(options, emitter, extraOptionDefs, logger) { const optionDefs = utils.extend({ logger: { default: logger } }, baseOptionDefs, extraOptionDefs); @@ -104,7 +134,15 @@ function validate(options, emitter, extraOptionDefs, logger) { reportArgumentError(messages.unknownOption(name)); } else { const expectedType = optionDef.type || typeDescForValue(optionDef.default); - if (expectedType !== 'any') { + const validator = optionDef.validator; + if (validator) { + const validated = validator(name, config, config[name], logger); + if (validated !== undefined) { + ret[name] = validated; + } else { + delete ret[name]; + } + } else if (expectedType !== 'any') { const allowedTypes = expectedType.split('|'); const actualType = typeDescForValue(value); if (allowedTypes.indexOf(actualType) < 0) { @@ -145,7 +183,30 @@ function validate(options, emitter, extraOptionDefs, logger) { return config; } +/** + * Get tags for the specified configuration. + * + * If any additional tags are added to the configuration, then the tags from + * this method should be extended with those. + * @param {Object} config The already valiated configuration. + * @returns {Object} The tag configuration. + */ +function getTags(config) { + const tags = {}; + if (config) { + if (config.application && config.application.id !== undefined && config.application.id !== null) { + tags['application-id'] = [config.application.id]; + } + if (config.application && config.application.version !== undefined && config.application.id !== null) { + tags['application-version'] = [config.application.version]; + } + } + + return tags; +} + module.exports = { baseOptionDefs, validate, + getTags, }; diff --git a/src/diagnosticEvents.js b/src/diagnosticEvents.js index db6a845..d27a891 100644 --- a/src/diagnosticEvents.js +++ b/src/diagnosticEvents.js @@ -5,6 +5,7 @@ const { v1: uuidv1 } = require('uuid'); const { baseOptionDefs } = require('./configuration'); const messages = require('./messages'); +const { appendUrlPath } = require('./utils'); function DiagnosticId(sdkKey) { const ret = { @@ -80,7 +81,7 @@ function DiagnosticsManager( ) { const combinedMode = !!platform.diagnosticUseCombinedEvent; const localStorageKey = 'ld:' + environmentId + ':$diagnostics'; - const diagnosticEventsUrl = config.eventsUrl + '/events/diagnostic/' + environmentId; + const diagnosticEventsUrl = appendUrlPath(config.eventsUrl, '/events/diagnostic/' + environmentId); const periodicInterval = config.diagnosticRecordingInterval; const acc = accumulator; const initialEventSamplingInterval = 4; // used only in combined mode - see start() diff --git a/src/headers.js b/src/headers.js new file mode 100644 index 0000000..9355f0d --- /dev/null +++ b/src/headers.js @@ -0,0 +1,38 @@ +const { getLDUserAgentString } = require('./utils'); +const configuration = require('./configuration'); + +function getLDHeaders(platform, options) { + if (options && !options.sendLDHeaders) { + return {}; + } + const h = {}; + h[platform.userAgentHeaderName || 'User-Agent'] = getLDUserAgentString(platform); + if (options && options.wrapperName) { + h['X-LaunchDarkly-Wrapper'] = options.wrapperVersion + ? options.wrapperName + '/' + options.wrapperVersion + : options.wrapperName; + } + const tags = configuration.getTags(options); + const tagKeys = Object.keys(tags); + if (tagKeys.length) { + h['x-launchdarkly-tags'] = tagKeys + .sort() + .flatMap( + key => (Array.isArray(tags[key]) ? tags[key].sort().map(value => `${key}/${value}`) : [`${key}/${tags[key]}`]) + ) + .join(' '); + } + return h; +} + +function transformHeaders(headers, options) { + if (!options || !options.requestHeaderTransform) { + return headers; + } + return options.requestHeaderTransform({ ...headers }); +} + +module.exports = { + getLDHeaders, + transformHeaders, +}; diff --git a/src/messages.js b/src/messages.js index 713a9f1..1df0b86 100644 --- a/src/messages.js +++ b/src/messages.js @@ -180,6 +180,8 @@ const debugPostingDiagnosticEvent = function(event) { return 'sending diagnostic event (' + event.kind + ')'; }; +const invalidTagValue = name => `Config option "${name}" must only contain letters, numbers, ., _ or -.`; + module.exports = { bootstrapInvalid, bootstrapOldFormat, @@ -207,6 +209,7 @@ module.exports = { invalidContentType, invalidData, invalidKey, + invalidTagValue, invalidUser, localStorageUnavailable, networkError, diff --git a/src/utils.js b/src/utils.js index 9bdbc9d..55182db 100644 --- a/src/utils.js +++ b/src/utils.js @@ -3,6 +3,13 @@ const fastDeepEqual = require('fast-deep-equal'); const userAttrsToStringify = ['key', 'secondary', 'ip', 'country', 'email', 'firstName', 'lastName', 'avatar', 'name']; +function appendUrlPath(baseUrl, path) { + // Ensure that URL concatenation is done correctly regardless of whether the + // base URL has a trailing slash or not. + const trimBaseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; + return trimBaseUrl + (path.startsWith('/') ? '' : '/') + path; +} + // See http://ecmanaut.blogspot.com/2006/07/encoding-decoding-utf8-in-javascript.html function btoa(s) { const escaped = unescape(encodeURIComponent(s)); @@ -150,27 +157,6 @@ function getLDUserAgentString(platform) { return platform.userAgent + '/' + version; } -function getLDHeaders(platform, options) { - if (options && !options.sendLDHeaders) { - return {}; - } - const h = {}; - h[platform.userAgentHeaderName || 'User-Agent'] = getLDUserAgentString(platform); - if (options && options.wrapperName) { - h['X-LaunchDarkly-Wrapper'] = options.wrapperVersion - ? options.wrapperName + '/' + options.wrapperVersion - : options.wrapperName; - } - return h; -} - -function transformHeaders(headers, options) { - if (!options || !options.requestHeaderTransform) { - return headers; - } - return options.requestHeaderTransform({ ...headers }); -} - function extend(...objects) { return objects.reduce((acc, obj) => ({ ...acc, ...obj }), {}); } @@ -196,18 +182,17 @@ function sanitizeUser(user) { } module.exports = { + appendUrlPath, base64URLEncode, btoa, chunkUserEventsForUrl, clone, deepEquals, extend, - getLDHeaders, getLDUserAgentString, objectHasOwnProperty, onNextTick, sanitizeUser, - transformHeaders, transformValuesToVersionedValues, transformVersionedValuesToValues, wrapPromiseCallback, diff --git a/test-types.ts b/test-types.ts index 787b635..c6c1564 100644 --- a/test-types.ts +++ b/test-types.ts @@ -47,7 +47,11 @@ var allBaseOptions: ld.LDOptionsBase = { sendEventsOnlyForVariation: true, flushInterval: 1, streamReconnectDelay: 1, - logger: logger + logger: logger, + application: { + version: 'version', + id: 'id' + } }; var client: ld.LDClientBase = {} as ld.LDClientBase; // wouldn't do this in real life, it's just so the following statements will compile diff --git a/typings.d.ts b/typings.d.ts index 9b33b1f..df5d33b 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -115,10 +115,14 @@ declare module 'launchdarkly-js-sdk-common' { /** * Whether or not to include custom HTTP headers when requesting flags from LaunchDarkly. * - * Currently these are used to track what version of the SDK is active. This defaults to true - * (custom headers will be sent). One reason you might want to set it to false is that the presence - * of custom headers causes browsers to make an extra OPTIONS request (a CORS preflight check) - * before each flag request, which could affect performance. + * These are used to send metadata about the SDK (such as the version). They + * are also used to send the application.id and application.version set in + * the options. + * + * This defaults to true (custom headers will be sent). One reason you might + * want to set it to false is that the presence of custom headers causes + * browsers to make an extra OPTIONS request (a CORS preflight check) before + * each flag request, which could affect performance. */ sendLDHeaders?: boolean; @@ -255,6 +259,31 @@ declare module 'launchdarkly-js-sdk-common' { * The default value is `false`. */ autoAliasingOptOut?: boolean; + + /** + * Information about the application where the LaunchDarkly SDK is running. + */ + application?: { + /** + * A unique identifier representing the application where the LaunchDarkly SDK is running. + * + * This can be specified as any string value as long as it only uses the following characters: ASCII letters, + * ASCII digits, period, hyphen, underscore. A string containing any other characters will be ignored. + * + * Example: `authentication-service` + */ + id?: string; + + /** + * A unique identifier representing the version of the application where the LaunchDarkly SDK is running. + * + * This can be specified as any string value as long as it only uses the following characters: ASCII letters, + * ASCII digits, period, hyphen, underscore. A string containing any other characters will be ignored. + * + * Example: `1.0.0` (standard version string) or `abcdef` (sha prefix) + */ + version?: string; + } } /**