From c6ca9d2c9e1c3d597982d6fd9db56b71deea1728 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 28 Sep 2022 15:38:27 -0700 Subject: [PATCH] Update with changes from main, remove frequent duplicate events option. (#75) --- .ldrelease/config.yml | 2 +- CHANGELOG.md | 18 ++++ package.json | 2 +- 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 | 47 +++++++++- src/__tests__/diagnosticEvents-test.js | 2 - src/__tests__/headers-test.js | 117 +++++++++++++++++++++++++ src/__tests__/utils-test.js | 87 ++---------------- src/configuration.js | 68 +++++++++++++- src/diagnosticEvents.js | 4 +- src/headers.js | 38 ++++++++ src/index.js | 13 +-- src/messages.js | 6 ++ src/utils.js | 31 ++----- test-types.ts | 7 +- typings.d.ts | 45 +++++++--- 20 files changed, 363 insertions(+), 147 deletions(-) create mode 100644 src/__tests__/headers-test.js create mode 100644 src/headers.js diff --git a/.ldrelease/config.yml b/.ldrelease/config.yml index 708a746..3b16893 100644 --- a/.ldrelease/config.yml +++ b/.ldrelease/config.yml @@ -5,7 +5,7 @@ repo: private: js-sdk-common-private branches: - - name: master + - name: main description: 4.x - name: 3.x diff --git a/CHANGELOG.md b/CHANGELOG.md index 80ee40f..3d4bc85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to the `launchdarkly-js-sdk-common` package will be documented in this file. Changes that affect the dependent SDKs such as `launchdarkly-js-client-sdk` should also be logged in those projects, in the next release that uses the updated version of this package. This project adheres to [Semantic Versioning](http://semver.org). +## [4.1.1] - 2022-06-07 +### Changed: +- Enforce a 64 character limit for `application.id` and `application.version` configuration options. + +### Fixed: +- Do not include deleted flags in `allFlags`. + +## [4.1.0] - 2022-04-21 +### Added: +- `LDOptionsBase.application`, for configuration of application metadata that may be used in LaunchDarkly analytics or other product features. This does not affect feature flag evaluations. + +### Fixed: +- The `baseUrl`, `streamUrl`, and `eventsUrl` properties now work properly regardless of whether the URL string has a trailing slash. Previously, a trailing slash would cause request URL paths to have double slashes. + ## [4.0.3] - 2022-02-16 ### 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. @@ -23,6 +37,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/package.json b/package.json index 9212412..47be04f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "launchdarkly-js-sdk-common", - "version": "4.0.3", + "version": "4.1.1", "description": "LaunchDarkly SDK for JavaScript - common code", "author": "LaunchDarkly ", "license": "Apache-2.0", diff --git a/src/EventProcessor.js b/src/EventProcessor.js index 5f7bcba..7eeb5b7 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 contextFilter = ContextFilter(options); const samplingInterval = options.samplingInterval; diff --git a/src/EventSender.js b/src/EventSender.js index fd4b7ab..cb561c2 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 0ec0170..a8f5217 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 context from LaunchDarkly. Returns a Promise diff --git a/src/Stream.js b/src/Stream.js index ff211b2..aee371e 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(context); } 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 8ed4dcb..348ea7b 100644 --- a/src/__tests__/configuration-test.js +++ b/src/__tests__/configuration-test.js @@ -102,7 +102,6 @@ describe('configuration', () => { checkBooleanProperty('sendEvents'); checkBooleanProperty('allAttributesPrivate'); checkBooleanProperty('sendLDHeaders'); - checkBooleanProperty('allowFrequentDuplicateEvents'); checkBooleanProperty('sendEventsOnlyForVariation'); checkBooleanProperty('useReport'); checkBooleanProperty('evaluationReasons'); @@ -213,4 +212,50 @@ 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('logs a warning when a tag value is too long', async () => { + const listener = errorListener(); + const configIn = { application: { id: 'a'.repeat(65), version: 'b'.repeat(64) } }; + expect(configuration.validate(configIn, listener.emitter, null, listener.logger).application.id).toBeUndefined(); + await listener.expectWarningOnly(messages.tagValueTooLong('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__/diagnosticEvents-test.js b/src/__tests__/diagnosticEvents-test.js index f4350ae..6280f76 100644 --- a/src/__tests__/diagnosticEvents-test.js +++ b/src/__tests__/diagnosticEvents-test.js @@ -101,7 +101,6 @@ describe('DiagnosticsManager', () => { }; const defaultConfigInEvent = { allAttributesPrivate: false, - allowFrequentDuplicateEvents: false, bootstrapMode: false, customBaseURI: false, customEventsURI: false, @@ -187,7 +186,6 @@ describe('DiagnosticsManager', () => { it('sends init event on start() with custom config', async () => { const configAndResultValues = [ [{ allAttributesPrivate: true }, { allAttributesPrivate: true }], - [{ allowFrequentDuplicateEvents: true }, { allowFrequentDuplicateEvents: true }], [{ bootstrap: {} }, { bootstrapMode: true }], [{ baseUrl: 'http://other' }, { customBaseURI: true }], [{ eventsUrl: 'http://other' }, { customEventsURI: true }], 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 8cebd7e..62ed93d 100644 --- a/src/__tests__/utils-test.js +++ b/src/__tests__/utils-test.js @@ -1,15 +1,15 @@ -import { - base64URLEncode, - getLDHeaders, - transformHeaders, - getLDUserAgentString, - wrapPromiseCallback, - chunkEventsForUrl, -} from '../utils'; +import { appendUrlPath, base64URLEncode, getLDUserAgentString, wrapPromiseCallback, chunkEventsForUrl } from '../utils'; 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 +48,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 14b0413..47acac1 100644 --- a/src/configuration.js +++ b/src/configuration.js @@ -20,7 +20,6 @@ const baseOptionDefs = { streaming: { type: 'boolean' }, // default for this is undefined, which is different from false sendLDHeaders: { default: true }, requestHeaderTransform: { type: 'function' }, - allowFrequentDuplicateEvents: { default: false }, sendEventsOnlyForVariation: { default: false }, useReport: { default: false }, evaluationReasons: { default: false }, @@ -36,8 +35,42 @@ const baseOptionDefs = { wrapperName: { type: 'string' }, wrapperVersion: { type: 'string' }, stateProvider: { type: 'object' }, // not a public option, used internally + 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 {string} tagValue + * @param {Object} logger + */ +function validateTagValue(name, tagValue, logger) { + if (typeof tagValue !== 'string' || !tagValue.match(allowedTagCharacters)) { + logger.warn(messages.invalidTagValue(name)); + return undefined; + } + if (tagValue.length > 64) { + logger.warn(messages.tagValueTooLong(name)); + return undefined; + } + return tagValue; +} + +function applicationConfigValidator(name, value, logger) { + const validated = {}; + if (value.id) { + validated.id = validateTagValue(`${name}.id`, value.id, logger); + } + if (value.version) { + validated.version = validateTagValue(`${name}.version`, value.version, logger); + } + return validated; +} + function validate(options, emitter, extraOptionDefs, logger) { const optionDefs = utils.extend({ logger: { default: logger } }, baseOptionDefs, extraOptionDefs); @@ -102,7 +135,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[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) { @@ -143,7 +184,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 0f8a827..dfc40a7 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() @@ -199,7 +200,6 @@ function DiagnosticsManager( usingSecureMode: !!config.hash, bootstrapMode: !!config.bootstrap, fetchGoalsDisabled: !config.fetchGoals, - allowFrequentDuplicateEvents: !!config.allowFrequentDuplicateEvents, sendEventsOnlyForVariation: !!config.sendEventsOnlyForVariation, }; // Client-side JS SDKs do not have the following properties which other SDKs have: 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/index.js b/src/index.js index d982ff3..ce19518 100644 --- a/src/index.js +++ b/src/index.js @@ -64,7 +64,6 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { const requestor = Requestor(platform, options, environment); - let seenRequests = {}; let flags = {}; let useLocalStorage; let streamActive; @@ -157,7 +156,6 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { } function onIdentifyChange(context) { - seenRequests = {}; sendIdentifyEvent(context); } @@ -179,15 +177,6 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { const context = ident.getContext(); const now = new Date(); const value = detail ? detail.value : null; - if (!options.allowFrequentDuplicateEvents) { - const cacheKey = JSON.stringify(value) + key; - const cached = seenRequests[cacheKey]; - // cache TTL is five minutes - if (cached && now - cached < 300000) { - return; - } - seenRequests[cacheKey] = now; - } const event = { kind: 'feature', @@ -319,7 +308,7 @@ function initialize(env, context, specifiedOptions, platform, extraOptionDefs) { } for (const key in flags) { - if (utils.objectHasOwnProperty(flags, key)) { + if (utils.objectHasOwnProperty(flags, key) && !flags[key].deleted) { results[key] = variationDetailInternal(key, null, !options.sendEventsOnlyForVariation).value; } } diff --git a/src/messages.js b/src/messages.js index fdf2080..a6fd409 100644 --- a/src/messages.js +++ b/src/messages.js @@ -180,6 +180,10 @@ const debugPostingDiagnosticEvent = function(event) { return 'sending diagnostic event (' + event.kind + ')'; }; +const invalidTagValue = name => `Config option "${name}" must only contain letters, numbers, ., _ or -.`; + +const tagValueTooLong = name => `Value of "${name}" was longer than 64 characters and was discarded.`; + module.exports = { bootstrapInvalid, bootstrapOldFormat, @@ -208,12 +212,14 @@ module.exports = { invalidData, invalidKey, invalidContext, + invalidTagValue, localStorageUnavailable, networkError, optionBelowMinimum, streamClosing, streamConnecting, streamError, + tagValueTooLong, unknownCustomEventKey, unknownOption, contextNotSpecified, diff --git a/src/utils.js b/src/utils.js index ba0f156..d841145 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 }), {}); } @@ -199,18 +185,17 @@ function sanitizeContext(context) { } module.exports = { + appendUrlPath, base64URLEncode, btoa, chunkEventsForUrl, clone, deepEquals, extend, - getLDHeaders, getLDUserAgentString, objectHasOwnProperty, onNextTick, sanitizeContext, - transformHeaders, transformValuesToVersionedValues, transformVersionedValuesToValues, wrapPromiseCallback, diff --git a/test-types.ts b/test-types.ts index a0d62d8..251c9f5 100644 --- a/test-types.ts +++ b/test-types.ts @@ -42,11 +42,14 @@ var allBaseOptions: ld.LDOptionsBase = { sendEvents: true, allAttributesPrivate: true, privateAttributes: [ 'x' ], - allowFrequentDuplicateEvents: true, 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 6cb8133..0217956 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; @@ -160,14 +164,6 @@ declare module 'launchdarkly-js-sdk-common' { */ privateAttributes?: Array; - /** - * Whether or not to send an analytics event for a flag evaluation even if the same flag was - * evaluated with the same value within the last five minutes. - * - * By default, this is false (duplicate events within five minutes will be dropped). - */ - allowFrequentDuplicateEvents?: boolean; - /** * Whether analytics events should be sent only when you call variation (true), or also when you * call allFlags (false). @@ -239,6 +235,31 @@ declare module 'launchdarkly-js-sdk-common' { * If `wrapperName` is unset, this field will be ignored. */ wrapperVersion?: string; + + /** + * 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; + } } /**