serverless-offline-msk
Version:
A serverless offline plugin that enables AWS MSK events
196 lines (171 loc) • 5.87 kB
JavaScript
;
const { join } = require('path');
const ensurePlainObject = require('type/plain-object/ensure');
const { v1: uuid } = require('uuid');
const fetch = require('node-fetch');
const fse = require('fs-extra');
const fsp = require('fs').promises;
const telemetryUrl = require('@serverless/utils/analytics-and-notfications-url');
const { log } = require('@serverless/utils/log');
const isTelemetryDisabled = require('./are-disabled');
const cacheDirPath = require('./cache-path');
const telemetryLog = log.get('telemetry');
const timestampWeekBefore = Date.now() - 1000 * 60 * 60 * 24 * 7;
const isUuid = RegExp.prototype.test.bind(
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
);
let serverlessRunEndTime;
const logError = (type, error) => {
telemetryLog.debug('User stats error: %s: %O', type, error);
if (!process.env.SLS_STATS_DEBUG) return;
};
const markServerlessRunEnd = () => (serverlessRunEndTime = Date.now());
const processResponseBody = async (response, ids, startTime) => {
let result;
try {
result = await response.json();
} catch (error) {
logError(`Response processing error for ${ids || '<no id>'}`, error);
return null;
}
const endTime = Date.now();
if (serverlessRunEndTime) {
telemetryLog.debug(
'Stats request prevented process from exiting for %dms (request time: %dms)',
endTime - serverlessRunEndTime,
endTime - startTime
);
}
return result;
};
async function request(payload, { ids, timeout } = {}) {
const startTime = Date.now();
let response;
const body = JSON.stringify(payload);
try {
response = await fetch(telemetryUrl, {
headers: {
'content-type': 'application/json',
},
method: 'POST',
// Ensure reasonable timeout to not block process from exiting
timeout: timeout || 3500,
body,
});
} catch (networkError) {
logError('Request network error', networkError);
return null;
}
if (response.status < 200 || response.status >= 300) {
logError('Unexpected request response', response);
return processResponseBody(response, ids, startTime);
}
if (!ids) return processResponseBody(response, ids, startTime);
await Promise.all(
ids.map(async (id) => {
const cachePath = join(cacheDirPath, id);
try {
await fsp.unlink(cachePath);
} catch (error) {
logError(`Could not remove cache file ${id}`, error);
}
})
);
return processResponseBody(response, ids, startTime);
}
// This method is explicitly kept as synchronous. The reason for it being the fact that it needs to
// be executed in such manner due to its use in `process.on('SIGINT')` handler.
function storeLocally(payload, options = {}) {
ensurePlainObject(payload);
if (!telemetryUrl) return null;
const isForced = options && options.isForced;
if (isTelemetryDisabled && !isForced) return null;
if (!cacheDirPath) return null;
const id = uuid();
return (function self() {
try {
// Additionally, we also append `id` to the payload to be used as $insert_id in Mixpanel to ensure event deduplication
return fse.writeJsonSync(join(cacheDirPath, id), {
payload: { ...payload, id },
timestamp: Date.now(),
});
} catch (error) {
if (error.code === 'ENOENT') {
try {
fse.ensureDirSync(cacheDirPath);
return self();
} catch (ensureDirError) {
logError('Cache dir creation error:', ensureDirError);
}
}
logError(`Write cache file error: ${id}`, error);
return null;
}
})();
}
async function send(options = {}) {
const isForced = options && options.isForced;
serverlessRunEndTime = null; // Needed for testing
if (options.serverlessExecutionSpan) {
options.serverlessExecutionSpan.then(markServerlessRunEnd, markServerlessRunEnd);
}
if (isTelemetryDisabled && !isForced) return null;
if (!cacheDirPath) return null;
if (!telemetryUrl) return null;
let dirFilenames;
try {
dirFilenames = await fsp.readdir(cacheDirPath);
} catch (readdirError) {
if (readdirError.code !== 'ENOENT') logError('Cannot access cache dir', readdirError);
return null;
}
const payloadsWithIds = (
await Promise.all(
dirFilenames.map(async (dirFilename) => {
if (!isUuid(dirFilename)) return null;
let data;
try {
data = await fse.readJson(join(cacheDirPath, dirFilename));
} catch (readJsonError) {
if (readJsonError.code === 'ENOENT') return null; // Race condition
logError(`Cannot read cache file: ${dirFilename}`, readJsonError);
const cacheFile = join(cacheDirPath, dirFilename);
try {
return await fsp.unlink(cacheFile);
} catch (error) {
logError(`Could not remove cache file ${dirFilename}`, error);
}
}
if (data && data.payload) {
const timestamp = Number(data.timestamp);
if (timestamp > timestampWeekBefore) {
return {
payload: data.payload,
id: dirFilename,
};
}
} else {
logError(`Invalid cached data ${dirFilename}`, data);
}
const cacheFile = join(cacheDirPath, dirFilename);
try {
return await fsp.unlink(cacheFile);
} catch (error) {
logError(`Could not remove cache file ${dirFilename}`, error);
}
return null;
})
)
).filter(Boolean);
if (!payloadsWithIds.length) return null;
return request(
payloadsWithIds
.map((item) => item.payload)
.sort((item, other) => item.timestamp - other.timestamp),
{
ids: payloadsWithIds.map((item) => item.id),
timeout: 3000,
}
);
}
module.exports = { storeLocally, send };