Source

src/api-manager.ts

/*!
 * Convert JS SDK
 * Version 1.0.0
 * Copyright(c) 2020 Convert Insights, Inc
 * License Apache-2.0
 */
import {ApiManagerInterface} from './interfaces/api-manager';

import {Config, ConfigData} from './types/Config';
import {objectDeepValue} from './utils/object-utils';
import {LogManagerInterface} from './interfaces/log-manager';
import {SystemEvents} from './enums/system-events';
import {EventManagerInterface} from './interfaces/event-manager';
import {Id} from './types/Id';
import {TrackingEvent} from './types/TrackingEvent';
import {SegmentsData} from './types/SegmentsData';
import {Visitor} from './types/tracking/Visitor';
import {VisitorEvent} from './types/tracking/VisitorEvent';
import {VisitorsQueue} from './types/VisitorsQueue';
import {Path} from './types/Path';
import httpClient, {
  HttpMethod,
  HttpResponse,
  HttpResponseType,
  HttpRequest
} from './utils/http-client';

import {
  DEFAULT_CONFIG_ENDPOINT,
  DEFAULT_TRACK_ENDPOINT
} from './config/default';

const DEFAULT_HEADERS = {
  'Content-type': 'application/json'
};
const DEFAULT_BATCH_SIZE = 10;
const DEFAULT_RELEASE_INTERVAL = 10000;

/**
 * Provides logic for network requests. Reads remote configuration and sends tracking events to Convert server.
 * @category Modules
 * @constructor
 * @implements {ApiManagerInterface}
 */
export class ApiManager implements ApiManagerInterface {
  private _requestsQueue: VisitorsQueue;
  private _requestsQueueTimerID: number;

  private readonly _configEndpoint: string = DEFAULT_CONFIG_ENDPOINT;
  private readonly _trackEndpoint: string = DEFAULT_TRACK_ENDPOINT;
  private readonly _defaultHeaders: Record<string, string> = DEFAULT_HEADERS;

  private _data: ConfigData;
  private _enrichData: boolean;
  private _loggerManager: LogManagerInterface | null;
  private _eventManager: EventManagerInterface | null;
  private _accountId: Id;
  private _projectId: Id;
  private _trackingEvent: TrackingEvent;

  readonly batchSize: number = DEFAULT_BATCH_SIZE;
  readonly releaseInterval: number = DEFAULT_RELEASE_INTERVAL;

  /**
   * @param {Config=} config
   * @param {Object=} dependencies
   * @param {EventManagerInterface=} dependencies.eventManager
   * @param {LogManagerInterface=} dependencies.loggerManager
   */
  constructor(
    config?: Config,
    {
      eventManager,
      loggerManager
    }: {
      eventManager?: EventManagerInterface;
      loggerManager?: LogManagerInterface;
    } = {}
  ) {
    this._loggerManager = loggerManager;
    this._eventManager = eventManager;

    this._configEndpoint = objectDeepValue(
      config,
      'api.endpoint.config',
      DEFAULT_CONFIG_ENDPOINT
    );
    this._trackEndpoint = objectDeepValue(
      config,
      'api.endpoint.track',
      DEFAULT_TRACK_ENDPOINT
    );
    this._data = objectDeepValue(config, 'data');
    this._enrichData = !config?.dataStore;

    this.batchSize =
      Number(objectDeepValue(config, 'events.batch_size')).valueOf() ||
      DEFAULT_BATCH_SIZE;

    this.releaseInterval =
      Number(objectDeepValue(config, 'events.release_interval')).valueOf() ||
      DEFAULT_RELEASE_INTERVAL;

    this._accountId = this._data?.account_id;
    this._projectId = this._data?.project?.id;
    this._trackingEvent = {
      enrichData: this._enrichData,
      accountId: this._accountId,
      projectId: this._projectId,
      visitors: []
    };
    this._requestsQueue = {
      length: 0,
      items: [],
      push(
        visitorId: Id,
        eventRequest: VisitorEvent,
        segments?: SegmentsData
      ): void {
        const visitorIndex = this.items.findIndex(
          (event) => event.visitorId === visitorId
        );
        if (visitorIndex !== -1) {
          this.items[visitorIndex].events.push(eventRequest);
        } else {
          const visitor: Visitor = {
            visitorId,
            events: [eventRequest]
          };
          if (segments) visitor.segments = segments;
          this.items.push(visitor);
        }
        this.length++;
      },
      reset(): void {
        this.items = [];
        this.length = 0;
      }
    };
  }

  /**
   * Send request to api server
   * @param method
   * @param path
   * @param data
   * @param headers
   * @return {Promise<HttpResponse>}
   */
  async request(
    method: string,
    path: Path,
    data: Record<string, any> = {},
    headers: Record<string, any> = {}
  ): Promise<HttpResponse> {
    const requestHeaders = {
      ...this._defaultHeaders,
      ...headers
    };
    const requestConfig: HttpRequest = {
      method: <HttpMethod>method,
      path: path.route,
      baseURL: path.base,
      headers: requestHeaders,
      data: data,
      responseType: <HttpResponseType>'json'
    };
    return httpClient.request(requestConfig);
  }

  /**
   * Add request to queue for sending to server
   * @param {Id} visitorId
   * @param {VisitorEvent} eventRequest
   * @param {SegmentsData} segments
   */
  enqueue(
    visitorId: Id,
    eventRequest: VisitorEvent,
    segments?: SegmentsData
  ): void {
    this._loggerManager?.trace?.('ApiManager.enqueue()', {
      eventRequest: eventRequest
    });
    this._requestsQueue.push(visitorId, eventRequest, segments);
    if (this._requestsQueue.length === this.batchSize) {
      this.releaseQueue('size').then();
    } else {
      if (this._requestsQueue.length === 1) {
        this.startQueue();
      }
    }
  }

  /**
   * Send queue to server
   * @param {string=} reason
   * @return {Promise<any>}
   */
  releaseQueue(reason?: string): Promise<any> {
    this._loggerManager?.trace?.('ApiManager.releaseQueue()', {
      reason: reason || ''
    });
    this.stopQueue();
    const payload: TrackingEvent = this._trackingEvent;
    payload.visitors = this._requestsQueue.items.slice();
    return this.request(
      'post',
      {
        base: this._trackEndpoint.replace(
          '[project_id]',
          this._projectId.toString()
        ),
        route: `/track/${this._accountId}/${this._projectId}`
      },
      payload
    )
      .then((result) => {
        this._requestsQueue.reset();
        this._eventManager?.fire?.(SystemEvents.API_QUEUE_RELEASED, {
          reason: reason,
          result: result
        });
      })
      .catch((error) => {
        // TODO: set an exponential backoff
        this._loggerManager?.error?.('ApiManager.releaseQueue()', {
          error: error.message
        });
        this.startQueue();
        this._eventManager?.fire?.(
          SystemEvents.API_QUEUE_RELEASED,
          {reason: reason},
          error
        );
      });
  }

  /**
   * Stop queue timer
   */
  stopQueue(): void {
    clearTimeout(this._requestsQueueTimerID);
  }

  /**
   * Start queue timer
   */
  startQueue(): void {
    this._requestsQueueTimerID = setTimeout(() => {
      this.releaseQueue('timeout');
    }, this.releaseInterval) as any;
  }

  /**
   * Set data
   */
  setData(data: ConfigData) {
    this._data = data;
    this._accountId = data?.account_id;
    this._projectId = data?.project?.id;
    this._trackingEvent.accountId = this._accountId;
    this._trackingEvent.projectId = this._projectId;
  }

  /**
   * Get config data by SDK key
   * @param {string} sdkKey
   * @return {Promise<ConfigData>}
   */
  getConfigByKey(sdkKey: string): Promise<ConfigData> {
    this._loggerManager?.trace?.('ApiManager.getConfigByKey()', {
      sdkKey
    });
    return new Promise((resolve, reject) => {
      this.request('get', {
        base: this._configEndpoint,
        route: `/config/${sdkKey}`
      })
        .then(({data}) => resolve(data))
        .catch(reject);
    });
  }
}