UNPKG

@amplitude/analytics-core

Version:
368 lines 17 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Destination = exports.getResponseBodyString = void 0; var tslib_1 = require("tslib"); var status_1 = require("../types/status"); var messages_1 = require("../types/messages"); var constants_1 = require("../types/constants"); var chunk_1 = require("../utils/chunk"); var result_builder_1 = require("../utils/result-builder"); var config_1 = require("../config"); var uuid_1 = require("../utils/uuid"); function getErrorMessage(error) { if (error instanceof Error) return error.message; return String(error); } function getResponseBodyString(res) { var responseBodyString = ''; try { if ('body' in res) { responseBodyString = JSON.stringify(res.body, null, 2); } } catch (_a) { // to avoid crash, but don't care about the error, add comment to avoid empty block lint error } return responseBodyString; } exports.getResponseBodyString = getResponseBodyString; var Destination = /** @class */ (function () { function Destination() { this.name = 'amplitude'; this.type = 'destination'; this.retryTimeout = 1000; this.throttleTimeout = 30000; this.storageKey = ''; // Indicator of whether events that are scheduled (but not flushed yet). // When flush: // 1. assign `scheduleId` to `flushId` // 2. set `scheduleId` to null this.scheduleId = null; // Timeout in milliseconds of current schedule this.scheduledTimeout = 0; // Indicator of whether current flush resolves. // When flush resolves, set `flushId` to null this.flushId = null; this.queue = []; } Destination.prototype.setup = function (config) { var _a; return tslib_1.__awaiter(this, void 0, void 0, function () { var unsent; var _this = this; return tslib_1.__generator(this, function (_b) { switch (_b.label) { case 0: this.config = config; this.storageKey = "".concat(constants_1.STORAGE_PREFIX, "_").concat(this.config.apiKey.substring(0, 10)); return [4 /*yield*/, ((_a = this.config.storageProvider) === null || _a === void 0 ? void 0 : _a.get(this.storageKey))]; case 1: unsent = _b.sent(); if (unsent && unsent.length > 0) { void Promise.all(unsent.map(function (event) { return _this.execute(event); })).catch(); } return [2 /*return*/, Promise.resolve(undefined)]; } }); }); }; Destination.prototype.execute = function (event) { var _this = this; // Assign insert_id for dropping invalid event later if (!event.insert_id) { event.insert_id = (0, uuid_1.UUID)(); } return new Promise(function (resolve) { var context = { event: event, attempts: 0, callback: function (result) { return resolve(result); }, timeout: 0, }; _this.queue.push(context); _this.schedule(_this.config.flushIntervalMillis); _this.saveEvents(); }); }; Destination.prototype.removeEventsExceedFlushMaxRetries = function (list) { var _this = this; return list.filter(function (context) { context.attempts += 1; if (context.attempts < _this.config.flushMaxRetries) { return true; } void _this.fulfillRequest([context], 500, messages_1.MAX_RETRIES_EXCEEDED_MESSAGE); return false; }); }; Destination.prototype.scheduleEvents = function (list) { var _this = this; list.forEach(function (context) { _this.schedule(context.timeout === 0 ? _this.config.flushIntervalMillis : context.timeout); }); }; // Schedule a flush in timeout when // 1. No schedule // 2. Timeout greater than existing timeout. // This makes sure that when throttled, no flush when throttle timeout expires. Destination.prototype.schedule = function (timeout) { var _this = this; if (this.config.offline) { return; } if (this.scheduleId === null || (this.scheduleId && timeout > this.scheduledTimeout)) { if (this.scheduleId) { clearTimeout(this.scheduleId); } this.scheduledTimeout = timeout; this.scheduleId = setTimeout(function () { _this.queue = _this.queue.map(function (context) { context.timeout = 0; return context; }); void _this.flush(true); }, timeout); return; } }; // Mark current schedule is flushed. Destination.prototype.resetSchedule = function () { this.scheduleId = null; this.scheduledTimeout = 0; }; // Flush all events regardless of their timeout Destination.prototype.flush = function (useRetry) { if (useRetry === void 0) { useRetry = false; } return tslib_1.__awaiter(this, void 0, void 0, function () { var list, later, batches; var _this = this; return tslib_1.__generator(this, function (_a) { switch (_a.label) { case 0: // Skip flush if offline if (this.config.offline) { this.resetSchedule(); this.config.loggerProvider.debug('Skipping flush while offline.'); return [2 /*return*/]; } if (this.flushId) { this.resetSchedule(); this.config.loggerProvider.debug('Skipping flush because previous flush has not resolved.'); return [2 /*return*/]; } this.flushId = this.scheduleId; this.resetSchedule(); list = []; later = []; this.queue.forEach(function (context) { return (context.timeout === 0 ? list.push(context) : later.push(context)); }); batches = (0, chunk_1.chunk)(list, this.config.flushQueueSize); // Promise.all() doesn't guarantee resolve order. // Sequentially resolve to make sure backend receives events in order return [4 /*yield*/, batches.reduce(function (promise, batch) { return tslib_1.__awaiter(_this, void 0, void 0, function () { return tslib_1.__generator(this, function (_a) { switch (_a.label) { case 0: return [4 /*yield*/, promise]; case 1: _a.sent(); return [4 /*yield*/, this.send(batch, useRetry)]; case 2: return [2 /*return*/, _a.sent()]; } }); }); }, Promise.resolve())]; case 1: // Promise.all() doesn't guarantee resolve order. // Sequentially resolve to make sure backend receives events in order _a.sent(); // Mark current flush is done this.flushId = null; this.scheduleEvents(this.queue); return [2 /*return*/]; } }); }); }; Destination.prototype.send = function (list, useRetry) { if (useRetry === void 0) { useRetry = true; } return tslib_1.__awaiter(this, void 0, void 0, function () { var payload, serverUrl, res, e_1, errorMessage; return tslib_1.__generator(this, function (_a) { switch (_a.label) { case 0: if (!this.config.apiKey) { return [2 /*return*/, this.fulfillRequest(list, 400, messages_1.MISSING_API_KEY_MESSAGE)]; } payload = { api_key: this.config.apiKey, events: list.map(function (context) { // eslint-disable-next-line @typescript-eslint/no-unused-vars var _a = context.event, extra = _a.extra, eventWithoutExtra = tslib_1.__rest(_a, ["extra"]); return eventWithoutExtra; }), options: { min_id_length: this.config.minIdLength, }, client_upload_time: new Date().toISOString(), request_metadata: this.config.requestMetadata, }; this.config.requestMetadata = new config_1.RequestMetadata(); _a.label = 1; case 1: _a.trys.push([1, 3, , 4]); serverUrl = (0, config_1.createServerConfig)(this.config.serverUrl, this.config.serverZone, this.config.useBatch).serverUrl; return [4 /*yield*/, this.config.transportProvider.send(serverUrl, payload)]; case 2: res = _a.sent(); if (res === null) { this.fulfillRequest(list, 0, messages_1.UNEXPECTED_ERROR_MESSAGE); return [2 /*return*/]; } if (!useRetry) { if ('body' in res) { this.fulfillRequest(list, res.statusCode, "".concat(res.status, ": ").concat(getResponseBodyString(res))); } else { this.fulfillRequest(list, res.statusCode, res.status); } return [2 /*return*/]; } this.handleResponse(res, list); return [3 /*break*/, 4]; case 3: e_1 = _a.sent(); errorMessage = getErrorMessage(e_1); this.config.loggerProvider.error(errorMessage); this.handleResponse({ status: status_1.Status.Failed, statusCode: 0 }, list); return [3 /*break*/, 4]; case 4: return [2 /*return*/]; } }); }); }; Destination.prototype.handleResponse = function (res, list) { var status = res.status; switch (status) { case status_1.Status.Success: { this.handleSuccessResponse(res, list); break; } case status_1.Status.Invalid: { this.handleInvalidResponse(res, list); break; } case status_1.Status.PayloadTooLarge: { this.handlePayloadTooLargeResponse(res, list); break; } case status_1.Status.RateLimit: { this.handleRateLimitResponse(res, list); break; } default: { // log intermediate event status before retry this.config.loggerProvider.warn("{code: 0, error: \"Status '".concat(status, "' provided for ").concat(list.length, " events\"}")); this.handleOtherResponse(list); break; } } }; Destination.prototype.handleSuccessResponse = function (res, list) { this.fulfillRequest(list, res.statusCode, messages_1.SUCCESS_MESSAGE); }; Destination.prototype.handleInvalidResponse = function (res, list) { var _this = this; if (res.body.missingField || res.body.error.startsWith(messages_1.INVALID_API_KEY)) { this.fulfillRequest(list, res.statusCode, res.body.error); return; } var dropIndex = tslib_1.__spreadArray(tslib_1.__spreadArray(tslib_1.__spreadArray(tslib_1.__spreadArray([], tslib_1.__read(Object.values(res.body.eventsWithInvalidFields)), false), tslib_1.__read(Object.values(res.body.eventsWithMissingFields)), false), tslib_1.__read(Object.values(res.body.eventsWithInvalidIdLengths)), false), tslib_1.__read(res.body.silencedEvents), false).flat(); var dropIndexSet = new Set(dropIndex); var retry = list.filter(function (context, index) { if (dropIndexSet.has(index)) { _this.fulfillRequest([context], res.statusCode, res.body.error); return; } return true; }); if (retry.length > 0) { // log intermediate event status before retry this.config.loggerProvider.warn(getResponseBodyString(res)); } var tryable = this.removeEventsExceedFlushMaxRetries(retry); this.scheduleEvents(tryable); }; Destination.prototype.handlePayloadTooLargeResponse = function (res, list) { if (list.length === 1) { this.fulfillRequest(list, res.statusCode, res.body.error); return; } // log intermediate event status before retry this.config.loggerProvider.warn(getResponseBodyString(res)); this.config.flushQueueSize /= 2; var tryable = this.removeEventsExceedFlushMaxRetries(list); this.scheduleEvents(tryable); }; Destination.prototype.handleRateLimitResponse = function (res, list) { var _this = this; var dropUserIds = Object.keys(res.body.exceededDailyQuotaUsers); var dropDeviceIds = Object.keys(res.body.exceededDailyQuotaDevices); var throttledIndex = res.body.throttledEvents; var dropUserIdsSet = new Set(dropUserIds); var dropDeviceIdsSet = new Set(dropDeviceIds); var throttledIndexSet = new Set(throttledIndex); var retry = list.filter(function (context, index) { if ((context.event.user_id && dropUserIdsSet.has(context.event.user_id)) || (context.event.device_id && dropDeviceIdsSet.has(context.event.device_id))) { _this.fulfillRequest([context], res.statusCode, res.body.error); return; } if (throttledIndexSet.has(index)) { context.timeout = _this.throttleTimeout; } return true; }); if (retry.length > 0) { // log intermediate event status before retry this.config.loggerProvider.warn(getResponseBodyString(res)); } var tryable = this.removeEventsExceedFlushMaxRetries(retry); this.scheduleEvents(tryable); }; Destination.prototype.handleOtherResponse = function (list) { var _this = this; var later = list.map(function (context) { context.timeout = context.attempts * _this.retryTimeout; return context; }); var tryable = this.removeEventsExceedFlushMaxRetries(later); this.scheduleEvents(tryable); }; Destination.prototype.fulfillRequest = function (list, code, message) { this.removeEvents(list); list.forEach(function (context) { return context.callback((0, result_builder_1.buildResult)(context.event, code, message)); }); }; /** * This is called on * 1) new events are added to queue; or * 2) response comes back for a request * * Update the event storage based on the queue */ Destination.prototype.saveEvents = function () { if (!this.config.storageProvider) { return; } var updatedEvents = this.queue.map(function (context) { return context.event; }); void this.config.storageProvider.set(this.storageKey, updatedEvents); }; /** * This is called on response comes back for a request */ Destination.prototype.removeEvents = function (eventsToRemove) { this.queue = this.queue.filter(function (queuedContext) { return !eventsToRemove.some(function (context) { return context.event.insert_id === queuedContext.event.insert_id; }); }); this.saveEvents(); }; return Destination; }()); exports.Destination = Destination; //# sourceMappingURL=destination.js.map