@amplitude/analytics-core
Version:
368 lines • 17 kB
JavaScript
"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