diff --git a/README.md b/README.md index 1f5e606..b2e5a9f 100644 --- a/README.md +++ b/README.md @@ -88,19 +88,19 @@ DLO is configurable by adding relevant options as `window` properties to the pag Additional configuration can be added using the below options. -| Option | Type | Default | Description | -| ------ | ---- | ------- | ----------- | -| _dlo_appender | LogAppender or string | `console` | Defines a custom log appender to redirect log messages. | -| _dlo_beforeDestination | OperatorOptions | `undefined` | An optional operator that is always used just before before the destination. | -| _dlo_logLevel | number | `1` | Log messages at this level and below will be logged by the LogAppender. Defaults to WARN. | -| _dlo_previewDestination | string | `'console.log'` | Output destination using rule selector syntax for use with previewMode. | -| _dlo_previewMode | boolean | `true` | Redirects output from a destination to previewDestination when testing rules. | -| _dlo_readOnLoad | boolean | `false` | When true reads data layer target(s) and emits the initial value(s). | -| _dlo_rules | array | `[]` | Anything that starts with `_dlo_rules` is read as a rules array. | -| _dlo_validateRules | boolean | `true` | When true validates rules to prevent processing invalid options. | -| _dlo_urlValidator | function | `(url) => boolean` | Function used to validate the page URL before executing the rules. The default tests `window.location.href`. | -| _dlo_telemetryProvider | TelemetryProvider | `DefaultTelemetryProvider` | Measures performance timings and client errors. | -| _dlo_telemetryExporter | TelemetryExporter | `nullTelemetryExporter` | Exports performance timings measured by the `DefaultTelemetryProvider` to a custom destination. Ignored when `_dlo_telemetryProvider` is defined. | +| Option | Type | Default | Description | +| ------ | ---- | ------- |-----------------------------------------------------------------------------------------------------------------------------------------------------| +| _dlo_appender | LogAppender or string | `console` | Defines a custom log appender to redirect log messages. | +| _dlo_beforeDestination | OperatorOptions | `undefined` | An optional operator that is always used just before before the `destination` or `fsApi`. Rules with `version` of `2` will skip `beforeDestiation`. | +| _dlo_logLevel | number | `1` | Log messages at this level and below will be logged by the LogAppender. Defaults to WARN. | +| _dlo_previewDestination | string | `'console.log'` | Output destination using rule selector syntax for use with previewMode. | +| _dlo_previewMode | boolean | `true` | Redirects output from a destination to previewDestination when testing rules. | +| _dlo_readOnLoad | boolean | `false` | When true reads data layer target(s) and emits the initial value(s). | +| _dlo_rules | array | `[]` | Anything that starts with `_dlo_rules` is read as a rules array. | +| _dlo_validateRules | boolean | `true` | When true validates rules to prevent processing invalid options. | +| _dlo_urlValidator | function | `(url) => boolean` | Function used to validate the page URL before executing the rules. The default tests `window.location.href`. | +| _dlo_telemetryProvider | TelemetryProvider | `DefaultTelemetryProvider` | Measures performance timings and client errors. | +| _dlo_telemetryExporter | TelemetryExporter | `nullTelemetryExporter` | Exports performance timings measured by the `DefaultTelemetryProvider` to a custom destination. Ignored when `_dlo_telemetryProvider` is defined. | ## Data Layer Rules @@ -109,7 +109,9 @@ To observe a data layer, DLO uses rules to define what to observe and how to han A rule is composed of three primary pieces of information: - `source` targets an object in the data layer using a selector. The selector can also be used to choose which data in an object is recorded. -- `destination` declares a function, which acts as a destination for data. The function is an API that already exists on the page - like `FS.event`. +- One of + - `destination` declares a function, which acts as a destination for data. The function is an API that already exists on the page - like `FS.event`. + - `fsApi` A built in Fullstory function like `setIdentity`, `trackEvent`, `setUserProperties` or `setPageProperties`. - `operators` provide intermediate transformations of the data between the source and destination. A rule is expressed as JSON and multiple rules are included in a `_dlo_rules` list. @@ -128,12 +130,12 @@ window['_dlo_rules'] = [ description: 'send CEDDL transaction transactionID and total properties to FS.event as an "Order Completed" event', source: 'digitalData.transaction[(transactionID,total)]', operators: [ { name: 'flatten' }, { name: 'insert', value: 'Order Completed' } ], - destination: 'FS.event' + fsApi: 'trackEvent' } ]; ``` -The above sample contains two rules that respectively send all properties in the `digitalData.user.profile` object to the function `FS.setUserVars` and sends specific properties from the `digitalData.transaction` object to the `FS.event` function. The mechanics of how this is done is explained in the [Operator Tutorial](https://github.com/fullstorydev/fullstory-data-layer-observer/tree/main/docs/operator_tutorial.md) in greater detail. +The above sample contains two rules that respectively send all properties in the `digitalData.user.profile` object to the function `FS.setUserVars` and sends specific properties from the `digitalData.transaction` object to the built in `trackEvent` function. The mechanics of how this is done is explained in the [Operator Tutorial](https://github.com/fullstorydev/fullstory-data-layer-observer/tree/main/docs/operator_tutorial.md) in greater detail. > **Tip**: While not required, a best practice is to include an `id` in every rule. The `id` should uniquely identify the rule for troubleshooting purposes. A `description` is also optional but often helps explain the intent of a rule. @@ -141,23 +143,27 @@ The above sample contains two rules that respectively send all properties in the Each rule provides a set of options for configuration. Options with an asterisk are required. -| Option | Default | Description | -| ------ | ------- | ----------- | -| `source`* | `undefined` | Data layer source object using selector syntax. | -| `destination`* | `undefined` | Destination function using selector syntax. | -| `debounce` | `250` | Milliseconds that must pass before multiple, sequential changes to a data layer are handled (increase for highly active data layers) | -| `debug` | `false` | Set to true if the rule should print debug for each operator transformation. | -| `description` | `undefined` | Text description of the rule. | -| `id` | `undefined` | Unique identifier for the rule. | -| `maxRetry` | `5` | The maximum number of attempts to search for an `undefined` data layer or test the `waitUntil` predicate. | -| `monitor` | `true` | Set to true to monitor property changes or function calls | -| `operators` | `[]` | List of operators that transform data before a destination. | -| `readOnLoad` | `false` | Rule-specific override for `window[‘_dlo_readOnLoad’]`. | -| `url` | `undefined` | Specifies a regular expression that enables the rule when the page URL matches. | -| `waitUntil` | `undefined` | Waits a desired number of milliseconds or predicate function's truthy return type before registering the rule. | +| Option | Default | Description | +|---------------|-------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| +| `source`* | `undefined` | Data layer source object using selector syntax. | +| `destination` | `undefined` | Destination function using selector syntax. Must have exactly one of `destination` or `fsApi`. | +| `fsApi` | `undefined` | Use a built in Fullstory function instead of a `destination`. | +| `debounce` | `250` | Milliseconds that must pass before multiple, sequential changes to a data layer are handled (increase for highly active data layers) | +| `debug` | `false` | Set to true if the rule should print debug for each operator transformation. | +| `description` | `undefined` | Text description of the rule. | +| `id` | `undefined` | Unique identifier for the rule. | +| `maxRetry` | `5` | The maximum number of attempts to search for an `undefined` data layer or test the `waitUntil` predicate. | +| `monitor` | `true` | Set to true to monitor property changes or function calls | +| `operators` | `[]` | List of operators that transform data before a `destination` or `fsApi`. | +| `readOnLoad` | `false` | Rule-specific override for `window[‘_dlo_readOnLoad’]`. | +| `url` | `undefined` | Specifies a regular expression that enables the rule when the page URL matches. | +| `waitUntil` | `undefined` | Waits a desired number of milliseconds or predicate function's truthy return type before registering the rule. | +| `version` | `1` | The rule version to use. Currently supported values are `1` (default) or `2`. Rule version `2` ignores any `beforeDestination` that is configured. | > **Tip:** Use `url` to limit when data is read from a data layer and enhance performance. +> **Tip:** Use `version` = `2` on any rule that you want to skip `beforeDestination` on. + ## Data Handling The configuration option `window['_dlo_readOnLoad'] = true;` can be used on static data layers, which should exist on the page prior to loading DLO. Many data layers are however dynamic: values change as the user interacts with the page. By default, DLO will attempt to monitor for changes to a data layer's object or any property within an object. (See the details on source selection on how this is done.) An observed change will cause the data to be handled. Note that property changes are debounced, which allows multiple property changes to a single object appear as a single data layer event. @@ -183,18 +189,57 @@ The `source` property uses a custom selector syntax. It’s most often seen as Selector syntax can be combined to create sophisticated queries to the data layer. For example, `digitalData.products[-1].attributes.availability[?(pickup)]` can be read as, "From the products list, return the last product's availability if it has the `pickup` property." This usage of selection can be helpful to record only significant events and disregard others. -The properties in the object returned from source selection will be monitored for changes. For example, using the selector `digitalData.cart` will observe *all* properties in the `digitalData.cart` object because no refinement of the data is done by the selector. Alternatively, `digitalData.cart[(cartID,price)]` will only observe *only* `cartID` and `price` in `digitalData.cart`. Similarly, `digitalData.cart.price[^(shipping)]` would observe all properties beginning with shipping. Selectors allow you to be specific about which data to send to a destination and only monitor changes on desired properties. +The properties in the object returned from source selection will be monitored for changes. For example, using the selector `digitalData.cart` will observe *all* properties in the `digitalData.cart` object because no refinement of the data is done by the selector. Alternatively, `digitalData.cart[(cartID,price)]` will only observe *only* `cartID` and `price` in `digitalData.cart`. Similarly, `digitalData.cart.price[^(shipping)]` would observe all properties beginning with shipping. Selectors allow you to be specific about which data to send to a `destination` or `fsApi` and only monitor changes on desired properties. > **Tip:** A selector returns the most current subject from the data layer at the lowest level. For example, `digitalData.cart.price[?(basePrice>=10)]` returns the `price` object - not `cart`. If you need `cart` returned, use `digitalData.cart` as the selector and the use the query operator. ## Destination Selection -For every `source` there must also be a `destination`. A `destination` is a JavaScript function that is located using selector syntax. (Though it’s often less expressive since dot notation is usually enough to find the function.) A simple example of a destination is `console.log`, which would print the output of a data layer to the console. +For every `source` there can also be a single `destination` (One `destination` or `fsApi` is required). A `destination` is a JavaScript function that is located using selector syntax. (Though it’s often less expressive since dot notation is usually enough to find the function.) A simple example of a destination is `console.log`, which would print the output of a data layer to the console. Because `destination` is a function, this gives DLO a very flexible way to hand off data to a third party. You can think of this hand off as DLO calling a specific JavaScript function with arguments. The arguments are the data emitted from the data layer and anything added by `operators`. In the simple `console.log` destination example, the object taken from the data layer would call `console.log(object)`, which would simply print the object on the console. A more practical example is calling FullStory’s [event](https://developer.fullstory.com/custom-events) function by setting the destination to `FS.event`. There’s one catch though: the object emitted from the data layer must be the second argument for `FS.event`. To add the first argument and move the object to the second, an operator will be used. The next section talks about operators and the [Operator Tutorial](https://github.com/fullstorydev/fullstory-data-layer-observer/tree/main/docs/operator_tutorial.md) explains the process in more detail. +## fsApi Reference +In place of a `destination`, a built in `fsApi` function can be specified. `fsApi` hides the complexity of setting up and calling the new version of the Fullstory browser [API](https://developer.fullstory.com/browser/getting-started/). Unlike `destination`, where you would need to specify the full Javascript call, you can just use the built in constants below to make the call. The [Fullstory Namespace](https://help.fullstory.com/hc/en-us/articles/360020624694-What-if-the-identifier-FS-is-used-by-another-script-on-my-site) used on the page is automatically discovered and used in the API calls. + +| Name | Function | Notes | +|---------------------|--------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------| +| `setIdentity` | [setIdentity](https://developer.fullstory.com/browser/identification/identify-users/) | Uses first value (required) as the identity, and (optional) second value as the `properties` parameter | +| `trackEvent` | [trackEvent](https://developer.fullstory.com/browser/capture-events/analytics-events/) | Uses first value (required) as the event name, and second value (required) as the properties | +| `setUserProperties` | [setProperties type=user](https://developer.fullstory.com/browser/identification/set-user-properties/) | Uses only first value (required) as the properties | +| `setPageProperties` | [setProperties type=page](https://developer.fullstory.com/browser/set-page-properties/) | Uses only first value (required) as the properties | + +Here are some example rules utilizing `fsApi` + +```javascript +window['_dlo_rules'] = [ + { + id: 'fs-uservars-user-all', + source: 'digitalData.user.profile[0]', + operators: [ { name: 'flatten' } ], + fsApi: 'setUserProperties' + }, + { + id: 'fs-event-transaction-id-total', + source: 'digitalData.transaction[(transactionID,total)]', + operators: [ { name: 'flatten' }, { name: 'insert', value: 'Order Completed' } ], + fsApi: 'trackEvent' + }, + { + id: 'fs-event-page-all', + source: 'digitalData.page.vars', + operators: [ { name: 'flatten' } ], + fsApi: 'setPageProperties' + }, + { + id: 'fs-identify-user', + source: 'digitalData.user.profile[0].id', + fsApi: 'setIdentity' + } +]; +``` ## Data Layer Operators If a source emits data and a destination is the recipient, what makes the two compatible? The answer is an `operator`. An operator is designed to perform light transformation of an object emitted from the data layer so that it can be received by a destination. Operators are chained together using the `operators` property in a rule. Each operator performs a small transformation and provides the result to the next operator. An operator can also choose to not pass along data (by returning `null`) at which point data handling stops and the destination is not called. @@ -221,13 +266,13 @@ Click an operator name for additional documentation. Every operator requires the `name` property. Additional options can be found by viewing an operator's documentation. -> **Tip:** If an operator is required every time, use the `window['_dlo_beforeDestination']` configuration option. This will define an operator that is always run just prior to a destination. This can make rule writing less tedious and is applicable to scenarios like `suffix` where a `FS` destination always requires a suffixed payload. +> **Tip:** If an operator is required every time, use the `window['_dlo_beforeDestination']` configuration option. This will define an operator that is always run just prior to a destination or fsApi. This can make rule writing less tedious and is applicable to scenarios like `suffix` where a `FS` destination always requires a suffixed payload. *Note: Rule version `2` ignores `beforeDestination`* ## Preview and Debug Rules Rule writing can sometimes take a few attempts to get it right. Fortunately, the following two options can help. -- When the configuration option `_dlo_previewMode` is set to `true`, output will be written to `console.log` rather than the `destination`. +- When the configuration option `_dlo_previewMode` is set to `true`, output will be written to `console.log` rather than the `destination` or `fsApi`. - When a particular rule's `debug` property is set to `true`, the incremental transformations performed by `operators` and additional logging will be written to `console.debug`. Viewing the JavaScript console with the above option set to `true` prints the following example. @@ -263,7 +308,7 @@ In addition to showing the data conversions, debug includes the following statis Once rules have been verified in a test environment, they can be moved to production. New rules can be added to any existing rules such as those in `window['_dlo_rules']`. Alternatively, new rules can be placed in their own rule set to allow contribution from different teams or parts of a site. This can be done by creating a new configuration option that begins with `_dlo_rules` - for example `window['_dlo_rules_cart_and_checkout']`. When DLO initializes all properties that begin with `_dlo_rules` will be processed. -After new rules are deployed, visit the site and ensure the data is being sent to the destination. One approach is to simply review in FullStory that the custom event or user variable has been set. If data is not found, set the `debug` property to `true` for any problematic rules. If problems still persist, see the Monitoring section to verify something else isn’t at fault. +After new rules are deployed, visit the site and ensure the data is being sent to the `destination` or `fsApi`. One approach is to simply review in FullStory that the custom event or user variable has been set. If data is not found, set the `debug` property to `true` for any problematic rules. If problems still persist, see the Monitoring section to verify something else isn’t at fault. ## Logging diff --git a/src/observer.ts b/src/observer.ts index c1c49ff..62e9f63 100644 --- a/src/observer.ts +++ b/src/observer.ts @@ -5,7 +5,14 @@ import DataHandler from './handler'; import { Logger, LogAppender, LogMessageType, LogMessage, LogLevel, } from './utils/logger'; -import { FunctionOperator } from './operators'; +import { + FunctionOperator, + InsertOperator, + SetIdentityOperator, + SetPagePropertiesOperator, + SetUserPropertiesOperator, + TrackEventOperator, +} from './operators'; import DataLayerTarget from './target'; import MonitorFactory from './monitor-factory'; import { errorType, Telemetry, telemetryType } from './utils/telemetry'; @@ -60,7 +67,7 @@ export interface DataLayerRule { debug?: boolean; source: string; operators?: OperatorOptions[]; - destination: string | Function; + destination?: string | Function; readOnLoad?: boolean; url?: string; id?: string; @@ -68,6 +75,15 @@ export interface DataLayerRule { monitor?: boolean; waitUntil?: number | Function; maxRetry?: number; + version?: number; + fsApi?: FS_API_CONSTANTS; +} + +export enum FS_API_CONSTANTS { + SET_IDENTITY = 'setIdentity', + TRACK_EVENT = 'trackEvent', + SET_USER_PROPERTIES = 'setUserProperties', + SET_PAGE_PROPERTIES = 'setPageProperties' } /** @@ -190,8 +206,13 @@ export class DataLayerObserver { * adding the operator, the DataHandler will be removed to prevent unexpected data processing. * @param handler to add operators to * @param options for operators used to configure each Operator + * @param destination The javascript function to execute (must be one of destination or fsApi) + * @param fsApi The special FullStory constant to be executed (must be one of destination or fsApi) + * @param version The version of DLO you are using. As of version 2, beforeDestination will not be used. */ - private addOperators(handler: DataHandler, options: OperatorOptions[], destination: string | Function) { + private addOperators(handler: DataHandler, options: OperatorOptions[], + destination: string | Function | undefined = undefined, fsApi: FS_API_CONSTANTS | undefined = undefined, + version: number = 1) { const { beforeDestination, previewDestination = 'console.log', previewMode } = this.config; try { @@ -200,16 +221,42 @@ export class DataLayerObserver { handler.push(this.getOperator(optionSet)); }); - // optionally perform a final transformation + // optionally perform a final transformation if version is 1 // useful if every rule needs the same operator run before the destination - if (beforeDestination) { + if (beforeDestination && (version === 1)) { const beforeOptions = Array.isArray(beforeDestination) ? beforeDestination : [beforeDestination]; beforeOptions.forEach((operator) => handler.push(this.getOperator(operator))); } - // end with destination - const func = previewMode ? previewDestination : destination; - handler.push(new FunctionOperator({ name: 'function', func })); + if (fsApi) { + switch (fsApi) { + case FS_API_CONSTANTS.SET_IDENTITY: + handler.push(new SetIdentityOperator({ name: FS_API_CONSTANTS.SET_IDENTITY })); + break; + case FS_API_CONSTANTS.SET_PAGE_PROPERTIES: + handler.push(new SetPagePropertiesOperator({ name: FS_API_CONSTANTS.SET_PAGE_PROPERTIES })); + break; + case FS_API_CONSTANTS.SET_USER_PROPERTIES: + handler.push(new SetUserPropertiesOperator({ name: FS_API_CONSTANTS.SET_USER_PROPERTIES })); + break; + case FS_API_CONSTANTS.TRACK_EVENT: + handler.push(new TrackEventOperator({ name: FS_API_CONSTANTS.TRACK_EVENT })); + break; + default: + Logger.getInstance().error(`Unexpected coding error: Unknown fsApi value ${fsApi}`); + } + } else if (destination) { + const func = previewMode ? previewDestination : destination; + // if the version is greater than 1 it should ignore beforeDestination but still add dlo output + if (version > 1) { + handler.push(new InsertOperator({ + name: 'insert', position: -1, value: 'dlo', + })); + } + handler.push(new FunctionOperator({ name: 'function', func })); + } else { + Logger.getInstance().error('Unexpected coding error: Missing fsApi or destination'); + } } catch (err) { this.removeHandler(handler); Logger.getInstance().error(LogMessageType.OperatorError, { operator: JSON.stringify(options) }); @@ -263,22 +310,26 @@ export class DataLayerObserver { * @param source from the rule monitoring the data layer * @param target from the data layer * @param options list of OperatorOptions to transform data before a destination - * @param destination function using selector syntax or native function * @param read when true reads data layer target and emit the initial value * @param monitor when true property changes or function calls re-run the operators * @param debug when true the rule prints debug for each Operator transformation * @param debounce number of milliseconds to debounce property assignments before handling the event + * @param version version of this rule, defaults to 1 + * @param destination function using selector syntax or native function + * @param fsApi special Fullstory API Constant * @throws error if an error occurs during handler creation */ registerTarget( source: string, target: DataLayerTarget, options: OperatorOptions[], - destination: string | Function, + destination: string | Function | undefined = undefined, + fsApi: FS_API_CONSTANTS | undefined = undefined, read = false, monitor = true, debug = false, debounce = DataHandler.DefaultDebounceTime, + version:number = 1, ): DataHandler { let workingTarget = target; const targetValue = workingTarget.value; @@ -289,8 +340,8 @@ export class DataLayerObserver { */ if (monitor && Array.isArray(targetValue)) { if (targetValue.push && targetValue.unshift) { - this.registerTarget(source, DataLayerTarget.find(`${target.path}.unshift`), options, destination, false, true, - debug, debounce); + this.registerTarget(source, DataLayerTarget.find(`${target.path}.unshift`), options, destination, fsApi, + false, true, debug, debounce, version); workingTarget = DataLayerTarget.find(`${target.path}.push`); } else { Logger.getInstance().warn(LogMessageType.MonitorCreateError, { @@ -303,7 +354,7 @@ export class DataLayerObserver { } const handler = this.addHandler(source, workingTarget, !!debug, debounce); - this.addOperators(handler, options, destination); + this.addOperators(handler, options, destination, fsApi, version); if (read) { // For read-on-load for targeted arrays we do a sort of manual fan-out of the items @@ -408,6 +459,8 @@ export class DataLayerObserver { source, operators = [], destination, + fsApi, + version, readOnLoad: ruleReadOnLoad, url, monitor = true, @@ -417,13 +470,37 @@ export class DataLayerObserver { // rule properties override global ones const readOnLoad = ruleReadOnLoad === undefined ? globalReadOnLoad : ruleReadOnLoad; - if (!source || !destination) { + if (!source) { Logger.getInstance().error(LogMessageType.RuleInvalid, - { rule: id, source, reason: `Missing ${source ? 'destination' : 'source'}` }); + { rule: id, source, reason: 'Missing source' }); Telemetry.error(errorType.invalidRuleError); return; } + // sanity check destination and fsApi parameters + if (!destination && !fsApi) { + Logger.getInstance().error(LogMessageType.OperatorError, { + reason: LogMessage.MissingDestination, + }); + Telemetry.error(errorType.operatorError); + return; + } + + if (destination && fsApi) { + Logger.getInstance().error(LogMessageType.OperatorError, { + reason: LogMessage.DuplicateDestination, + }); + Telemetry.error(errorType.operatorError); + return; + } + + if (fsApi && !Object.values(FS_API_CONSTANTS).includes(fsApi as FS_API_CONSTANTS)) { + const reason = Logger.format(LogMessage.UnsupportedFsApi, fsApi); + Logger.getInstance().error(LogMessageType.OperatorError, { reason }); + Telemetry.error(errorType.operatorError); + return; + } + // check the rule is valid for the url if (!this.isUrlValid(url)) { return; @@ -432,7 +509,8 @@ export class DataLayerObserver { try { const register = () => { const target = DataLayerTarget.find(source); - this.registerTarget(source, target, operators, destination, readOnLoad, monitor, debug, debounce); + this.registerTarget(source, target, operators, destination, fsApi, readOnLoad, monitor, debug, + debounce, version); }; const timeout = () => Logger.getInstance().warn(LogMessageType.RuleRegistrationError, { rule: id, source, reason: 'Max Retries Attempted', diff --git a/src/operators/fsApi/fsApi.ts b/src/operators/fsApi/fsApi.ts new file mode 100644 index 0000000..1943409 --- /dev/null +++ b/src/operators/fsApi/fsApi.ts @@ -0,0 +1,42 @@ +import { Operator, OperatorOptions, OperatorValidator } from '../../operator'; +import { getGlobal } from '../../utils/object'; + +export interface FSApiOperatorOptions extends OperatorOptions { + +} + +export default abstract class FSApiOperator implements Operator { + static specification = {}; + + constructor(public options:FSApiOperatorOptions) { + // sets this.options + } + + handleData(data: any[]): any[] | null { + const thisArg: object = getGlobal(); + // @ts-ignore + const fsFunction:any = thisArg[thisArg._fs_namespace]; // eslint-disable-line + if (typeof fsFunction !== 'function') { + throw new Error('_fs_namespace is not a function'); + } + // subclasses will determine how to prepare the data + const realData = this.prepareData(data); + if (realData === null) { + return null; + } + // make sure to push dlo as last parameter + realData.push('dlo'); + const returnValue = fsFunction.apply(thisArg, realData); + if (returnValue === undefined || returnValue === null) { + return null; + } + return [returnValue]; + } + + validate() { + const validator = new OperatorValidator(this.options); + validator.validate(FSApiOperator.specification); + } + + abstract prepareData(inputData:any[]): any[] | null; +} diff --git a/src/operators/fsApi/setIdentity.ts b/src/operators/fsApi/setIdentity.ts new file mode 100644 index 0000000..5d9c4fa --- /dev/null +++ b/src/operators/fsApi/setIdentity.ts @@ -0,0 +1,21 @@ +import FSApiOperator from './fsApi'; + +// eslint-disable-next-line import/prefer-default-export +export class SetIdentityOperator extends FSApiOperator { + // eslint-disable-next-line class-methods-use-this + prepareData(inputData:any[]): any[] | null { + if (inputData === null || inputData.length < 1) { + throw new Error('Input data is empty'); + } + const realData:any[] = [ + 'setIdentity', + { uid: inputData[0] }, + ]; + // setIdentity calls can have optional properties + if (inputData.length > 1) { + // eslint-disable-next-line prefer-destructuring + realData[1].properties = inputData[1]; + } + return realData; + } +} diff --git a/src/operators/fsApi/setPageProperties.ts b/src/operators/fsApi/setPageProperties.ts new file mode 100644 index 0000000..34f8dfa --- /dev/null +++ b/src/operators/fsApi/setPageProperties.ts @@ -0,0 +1,18 @@ +import FSApiOperator from './fsApi'; + +// eslint-disable-next-line import/prefer-default-export +export class SetPagePropertiesOperator extends FSApiOperator { + // eslint-disable-next-line class-methods-use-this + prepareData(inputData:any[]): any[] | null { + if (inputData === null || inputData.length < 1) { + throw new Error('Input data is empty'); + } + return [ + 'setProperties', + { + type: 'page', + properties: inputData[0], + }, + ]; + } +} diff --git a/src/operators/fsApi/setUserProperties.ts b/src/operators/fsApi/setUserProperties.ts new file mode 100644 index 0000000..ea3c0a5 --- /dev/null +++ b/src/operators/fsApi/setUserProperties.ts @@ -0,0 +1,18 @@ +import FSApiOperator from './fsApi'; + +// eslint-disable-next-line import/prefer-default-export +export class SetUserPropertiesOperator extends FSApiOperator { + // eslint-disable-next-line class-methods-use-this + prepareData(inputData:any[]): any[] | null { + if (inputData === null || inputData.length < 1) { + throw new Error('Input data is empty'); + } + return [ + 'setProperties', + { + type: 'user', + properties: inputData[0], + }, + ]; + } +} diff --git a/src/operators/fsApi/trackEvent.ts b/src/operators/fsApi/trackEvent.ts new file mode 100644 index 0000000..1bb69d1 --- /dev/null +++ b/src/operators/fsApi/trackEvent.ts @@ -0,0 +1,20 @@ +import FSApiOperator from './fsApi'; + +// eslint-disable-next-line import/prefer-default-export +export class TrackEventOperator extends FSApiOperator { + // eslint-disable-next-line class-methods-use-this + prepareData(inputData:any[]): any[] | null { + if (inputData === null || inputData.length < 1) { + throw new Error('Input data is empty'); + } else if (inputData.length < 2) { + throw new Error('Input data expected to have two parameters'); + } + return [ + 'trackEvent', + { + name: inputData[0], + properties: inputData[1], + }, + ]; + } +} diff --git a/src/operators/index.ts b/src/operators/index.ts index ca94167..489fde4 100644 --- a/src/operators/index.ts +++ b/src/operators/index.ts @@ -7,3 +7,7 @@ export * from './suffix'; export * from './rename'; export * from './query'; export * from './parse'; +export * from './fsApi/setIdentity'; +export * from './fsApi/setPageProperties'; +export * from './fsApi/setUserProperties'; +export * from './fsApi/trackEvent'; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 2c07562..67b5ad9 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -34,6 +34,8 @@ export enum LogMessageType { export enum LogMessage { DataLayerMissing = 'Data layer not found', DuplicateValue = 'Value $0 already used', + DuplicateDestination = 'Only one of destination or fsApi can be defined', + MissingDestination = 'destination or fsApi must be defined', ShimFail = 'Shim not allowed because object is $0', SelectorInvalidIndex = 'Selector index $0 is not a number in $1', SelectorIncorrectTokenCount = 'Selector has incorrect number ($0) of tokens in $1', @@ -45,6 +47,7 @@ export enum LogMessage { TargetPropertyMissing = 'Target property is missing', TargetPathMissing = 'Target path is missing', UnknownValue = 'Unknown value $0', + UnsupportedFsApi = 'Unsupported fsApi $0', UnsupportedType = 'Unsupported type $0', } diff --git a/test/mocks/fullstoryV2.ts b/test/mocks/fullstoryV2.ts new file mode 100644 index 0000000..14ee94d --- /dev/null +++ b/test/mocks/fullstoryV2.ts @@ -0,0 +1,13 @@ +let callQueues: any[] = []; + +export function fullstoryMock(...args:any[]) { + callQueues.push(args); +} + +export function clearCallQueues() { + callQueues = []; +} + +export function getCallQueues() : any { + return callQueues; +} diff --git a/test/observer.spec.ts b/test/observer.spec.ts index e2ff9b6..cc2f14a 100644 --- a/test/observer.spec.ts +++ b/test/observer.spec.ts @@ -364,6 +364,59 @@ describe('DataLayerObserver unit tests', () => { ExpectObserver.getInstance().cleanup(observer); }); + it('it should ignore beforeDestination on version 2 rule', () => { + expectNoCalls(globalMock.console, 'log'); + + const observer = ExpectObserver.getInstance().create({ beforeDestination: { name: 'toUpper' }, rules: [] }); + + observer.registerOperator('toUpper', new UppercaseOperator()); + observer.registerRule({ + source: 'digitalData.page.category', + operators: [], + destination: 'console.log', + version: 2, + monitor: false, + }); + + expect(observer.handlers.length).to.eq(1); + + observer.handlers[0].fireEvent(); + + const [category] = expectParams(globalMock.console, 'log'); + // shouldn't be upper case as beforeDestination is ignored on version 2 rules + expect((category as PageCategory).primaryCategory).to.eq( + globalMock.digitalData.page.category.primaryCategory, + ); + + ExpectObserver.getInstance().cleanup(observer); + }); + + it('it should add dlo as extra parameter on version 2 rule', () => { + expectNoCalls(globalMock.console, 'log'); + + const observer = ExpectObserver.getInstance().create({ beforeDestination: { name: 'toUpper' }, rules: [] }); + + observer.registerOperator('toUpper', new UppercaseOperator()); + observer.registerRule({ + source: 'digitalData.page.category', + operators: [], + destination: 'console.log', + version: 2, + monitor: false, + }); + + expect(observer.handlers.length).to.eq(1); + + observer.handlers[0].fireEvent(); + + // @ts-ignore + const [category, dlo] = expectParams(globalMock.console, 'log'); + // shouldn't be upper case as beforeDestination is ignored on version 2 rules + expect(dlo).to.eq('dlo'); + + ExpectObserver.getInstance().cleanup(observer); + }); + it('it should register and call multiple operators before the destination', () => { const observer = ExpectObserver.getInstance().create({ beforeDestination: [ @@ -710,7 +763,7 @@ describe('DataLayerObserver unit tests', () => { expect(target).to.not.be.undefined; observer.registerTarget('digitalData.user.profile[0]', target, [{ name: 'query', select: '$[(profileID)]' }], - (...data: any[]) => { changes = data; }, true); + (...data: any[]) => { changes = data; }, undefined, true); // check the readOnLoad const [read] = changes; diff --git a/test/operator-fsApi.spec.ts b/test/operator-fsApi.spec.ts new file mode 100644 index 0000000..92eb602 --- /dev/null +++ b/test/operator-fsApi.spec.ts @@ -0,0 +1,248 @@ +import { expect } from 'chai'; +import 'mocha'; + +import { + SetIdentityOperator, SetUserPropertiesOperator, SetPagePropertiesOperator, TrackEventOperator, +} from '../src/operators'; +import { + ConsoleAppender, + DataLayerObserver, FS_API_CONSTANTS, Logger, LogMessage, LogMessageType, +} from '../src'; +import Console from './mocks/console'; +import { expectNoCalls, expectParams } from './utils/mocha'; +import { clearCallQueues, fullstoryMock, getCallQueues } from './mocks/fullstoryV2'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const originalConsole = globalThis.console; +const console = new Console(); + +describe('fsApi operator unit tests', () => { + beforeEach(() => { + Logger.getInstance().appender = new ConsoleAppender(); + (globalThis as any).console = console; + // eslint-disable-next-line no-underscore-dangle + (globalThis as any)._fs_namespace = 'FS'; + (globalThis as any).FS = fullstoryMock; + }); + + afterEach(() => { + // eslint-disable-next-line no-underscore-dangle + (globalThis as any).console = originalConsole; + clearCallQueues(); + // eslint-disable-next-line no-underscore-dangle + delete (globalThis as any)._fs_namespace; + delete (globalThis as any).FS; + }); + + it('it should validate options', () => { + expect(() => new SetIdentityOperator({ name: 'setIdentity' }).validate()).to.not.throw(); + expect(() => new SetUserPropertiesOperator({ name: 'setUserProperties' }).validate()).to.not.throw(); + expect(() => new SetPagePropertiesOperator({ name: 'setPageProperties' }).validate()).to.not.throw(); + expect(() => new TrackEventOperator({ name: 'trackEvent' }).validate()).to.not.throw(); + }); + + it('it should log error when missing both fsApi and destination', () => { + const observer = new DataLayerObserver(); + // no fsApi or destination + observer.registerRule({ source: 'foo' }); + const [error] = expectParams(console, 'error'); + expect(error).to.eq(`${LogMessageType.OperatorError} ${JSON.stringify({ reason: LogMessage.MissingDestination })}`); + }); + + it('it should log error when both fsApi and destination are present', () => { + const observer = new DataLayerObserver(); + // no fsApi or destination + // @ts-ignore + observer.registerRule({ source: 'foo', fsApi: 'bar', destination: 'test' }); + const [error] = expectParams(console, 'error'); + expect(error).to.eq( + `${LogMessageType.OperatorError} ${JSON.stringify({ reason: LogMessage.DuplicateDestination })}`, + ); + }); + + it('it should not log error when either fsApi or destination are present', () => { + const observer = new DataLayerObserver(); + (globalThis as any).foo = 'test'; + (globalThis as any).bar = () => console.log('Test'); + // no fsApi or destination + observer.registerRule({ source: 'foo', fsApi: FS_API_CONSTANTS.SET_IDENTITY }); + expectNoCalls(console, 'error'); + observer.registerRule({ source: 'foo', destination: 'bar' }); + expectNoCalls(console, 'error'); + delete (globalThis as any).foo; + delete (globalThis as any).bar; + }); + + it('it should log error when non supported fsApi is used', () => { + const observer = new DataLayerObserver(); + (globalThis as any).foo = 'test'; + // no fsApi or destination + // @ts-ignore + observer.registerRule({ source: 'foo', fsApi: 'bar' }); + const [error] = expectParams(console, 'error'); + const reason = Logger.format(LogMessage.UnsupportedFsApi, 'bar'); + expect(error).to.eq( + `${LogMessageType.OperatorError} ${JSON.stringify({ reason })}`, + ); + delete (globalThis as any).foo; + }); + + it('it should throw error when missing FullStory function', () => { + // this tests base class FSApiOperator so one example will be used + const operator = new SetIdentityOperator({ name: 'setIdentity' }); + expect(() => operator.handleData([])).to.throw(); + // eslint-disable-next-line no-underscore-dangle + delete (globalThis as any)._fs_namespace; + expect(() => operator.handleData([])).to.throw(); + // eslint-disable-next-line no-underscore-dangle + (globalThis as any)._fs_namespace = 'FS'; + expect(() => operator.handleData([])).to.throw(); + delete (globalThis as any).FS; + expect(() => operator.handleData([])).to.throw(); + }); + + it('it should process trackEvent properly', () => { + const operator = new TrackEventOperator({ name: 'trackEvent' }); + const inputData = [ + 'Test Event', + { + inputValue: 'My Input Value', + anotherValue: 5, + }, + ]; + const expectedOutput = [ + 'trackEvent', + { + name: inputData[0], + properties: inputData[1], + }, + 'dlo', + ]; + operator.handleData(inputData); + const callQueues = getCallQueues(); + expect(callQueues).to.not.be.null; + expect(callQueues.length).to.eq(1); + const output = callQueues[0]; + expect(output).to.deep.eq(expectedOutput); + }); + + it('it should throw error on improper trackEvent data', () => { + const operator = new TrackEventOperator({ name: 'trackEvent' }); + const inputData = [ + { + inputValue: 'My Input Value', + anotherValue: 5, + }, + ]; + expect(() => operator.handleData(inputData)).to.throw(); + }); + + it('it should process set user properties properly', () => { + const operator = new SetUserPropertiesOperator({ name: 'userProperties' }); + const inputData = [ + { + inputValue: 'My Input Value', + anotherValue: 5, + }, + ]; + const expectedOutput = [ + 'setProperties', + { + type: 'user', + properties: inputData[0], + }, + 'dlo', + ]; + operator.handleData(inputData); + const callQueues = getCallQueues(); + expect(callQueues).to.not.be.null; + expect(callQueues.length).to.eq(1); + const output = callQueues[0]; + expect(output).to.deep.eq(expectedOutput); + }); + + it('it should throw error on improper user properties data', () => { + const operator = new SetUserPropertiesOperator({ name: 'user properties' }); + const inputData:any = []; + expect(() => operator.handleData(inputData)).to.throw(); + }); + + it('it should process set page properties properly', () => { + const operator = new SetPagePropertiesOperator({ name: 'pageProperties' }); + const inputData = [ + { + inputValue: 'My Input Value', + anotherValue: 5, + }, + ]; + const expectedOutput = [ + 'setProperties', + { + type: 'page', + properties: inputData[0], + }, + 'dlo', + ]; + operator.handleData(inputData); + const callQueues = getCallQueues(); + expect(callQueues).to.not.be.null; + expect(callQueues.length).to.eq(1); + const output = callQueues[0]; + expect(output).to.deep.eq(expectedOutput); + }); + + it('it should throw error on improper page properties data', () => { + const operator = new SetPagePropertiesOperator({ name: 'page properties' }); + const inputData:any = []; + expect(() => operator.handleData(inputData)).to.throw(); + }); + + it('it should process set identity with no properties properly', () => { + const operator = new SetIdentityOperator({ name: 'setIdentity' }); + const inputData = ['12345']; + const expectedOutput = [ + 'setIdentity', + { + uid: inputData[0], + }, + 'dlo', + ]; + operator.handleData(inputData); + const callQueues = getCallQueues(); + expect(callQueues).to.not.be.null; + expect(callQueues.length).to.eq(1); + const output = callQueues[0]; + expect(output).to.deep.eq(expectedOutput); + }); + + it('it should process set identity with properties properly', () => { + const operator = new SetIdentityOperator({ name: 'setIdentity' }); + const inputData = [ + '12345', + { + inputValue: 'My Input Value', + anotherValue: 5, + }, + ]; + const expectedOutput = [ + 'setIdentity', + { + uid: inputData[0], + properties: inputData[1], + }, + 'dlo', + ]; + operator.handleData(inputData); + const callQueues = getCallQueues(); + expect(callQueues).to.not.be.null; + expect(callQueues.length).to.eq(1); + const output = callQueues[0]; + expect(output).to.deep.eq(expectedOutput); + }); + + it('it should throw error on improper setIdentity data', () => { + const operator = new SetIdentityOperator({ name: 'setIdentity' }); + const inputData:any = []; + expect(() => operator.handleData(inputData)).to.throw(); + }); +});