feat: medusa-telemetry (#328)
* feat: adds a telemetry package to collect anonymous usage data * fix: update telemetry host * fix: adds medusa telemetry --disable * fix: add tracking of link,login,new * fix: interactively collect db credentials * fix: require seed file * fix: removes tracking from reporter
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { join } from "path"
|
||||
import { fork } from "child_process"
|
||||
import isTruthy from "./is-truthy"
|
||||
|
||||
const MEDUSA_TELEMETRY_VERBOSE = process.env.MEDUSA_TELEMETRY_VERBOSE || false
|
||||
|
||||
function createFlush(enabled) {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
return async function flush() {
|
||||
if (isTruthy(MEDUSA_TELEMETRY_VERBOSE)) {
|
||||
console.log("Flushing queue...")
|
||||
}
|
||||
|
||||
const forked = fork(join(__dirname, `send.js`), {
|
||||
detached: true,
|
||||
stdio: MEDUSA_TELEMETRY_VERBOSE ? `inherit` : `ignore`,
|
||||
execArgv: [],
|
||||
})
|
||||
forked.unref()
|
||||
}
|
||||
}
|
||||
|
||||
export default createFlush
|
||||
@@ -0,0 +1,12 @@
|
||||
function getTermProgram() {
|
||||
const { TERM_PROGRAM, WT_SESSION } = process.env
|
||||
if (TERM_PROGRAM) {
|
||||
return TERM_PROGRAM
|
||||
} else if (WT_SESSION) {
|
||||
// https://github.com/microsoft/terminal/issues/1040
|
||||
return `WindowsTerminal`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export default getTermProgram
|
||||
@@ -0,0 +1,47 @@
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
import os from "os"
|
||||
import { join } from "path"
|
||||
|
||||
export class InMemoryConfigStore {
|
||||
config = {}
|
||||
path = join(os.tmpdir(), `medusa`)
|
||||
|
||||
constructor() {
|
||||
this.config = this.createBaseConfig()
|
||||
}
|
||||
|
||||
createBaseConfig() {
|
||||
return {
|
||||
"telemetry.enabled": true,
|
||||
"telemetry.machine_id": `not-a-machine-id-${uuidv4()}`,
|
||||
}
|
||||
}
|
||||
|
||||
get(key) {
|
||||
return this.config[key]
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
this.config[key] = value
|
||||
}
|
||||
|
||||
all() {
|
||||
return this.config
|
||||
}
|
||||
|
||||
size() {
|
||||
return Object.keys(this.config).length
|
||||
}
|
||||
|
||||
has(key) {
|
||||
return !!this.config[key]
|
||||
}
|
||||
|
||||
del(key) {
|
||||
delete this.config[key]
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.config = this.createBaseConfig()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Returns true for `true`, true, positive numbers
|
||||
// Returns false for `false`, false, 0, negative integers and anything else
|
||||
function isTruthy(value) {
|
||||
// Return if Boolean
|
||||
if (typeof value === `boolean`) return value
|
||||
|
||||
// Return false if null or undefined
|
||||
if (value === undefined || value === null) return false
|
||||
|
||||
// If the String is true or false
|
||||
if (value.toLowerCase() === `true`) return true
|
||||
if (value.toLowerCase() === `false`) return false
|
||||
|
||||
// Now check if it's a number
|
||||
const number = parseInt(value, 10)
|
||||
if (isNaN(number)) return false
|
||||
if (number > 0) return true
|
||||
|
||||
// Default to false
|
||||
return false
|
||||
}
|
||||
|
||||
export default isTruthy
|
||||
@@ -0,0 +1,121 @@
|
||||
import path from "path"
|
||||
import {
|
||||
appendFileSync,
|
||||
statSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
readdirSync,
|
||||
existsSync,
|
||||
unlinkSync,
|
||||
} from "fs"
|
||||
|
||||
import isTruthy from "./is-truthy"
|
||||
|
||||
const MEDUSA_TELEMETRY_VERBOSE = process.env.MEDUSA_TELEMETRY_VERBOSE || false
|
||||
|
||||
class Outbox {
|
||||
constructor(baseDir) {
|
||||
this.eventsJsonFileName = `events.json`
|
||||
this.bufferFilePath = path.join(baseDir, this.eventsJsonFileName)
|
||||
this.baseDir = baseDir
|
||||
}
|
||||
|
||||
appendToBuffer(event) {
|
||||
try {
|
||||
appendFileSync(this.bufferFilePath, event, `utf8`)
|
||||
} catch (e) {
|
||||
if (isTruthy(MEDUSA_TELEMETRY_VERBOSE)) {
|
||||
console.error("Failed to append to buffer", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSize() {
|
||||
if (!existsSync(this.bufferFilePath)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = statSync(this.bufferFilePath)
|
||||
return stats.size
|
||||
} catch (e) {
|
||||
if (isTruthy(MEDUSA_TELEMETRY_VERBOSE)) {
|
||||
console.error("Failed to get outbox size", e)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
getCount() {
|
||||
if (!existsSync(this.bufferFilePath)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
try {
|
||||
const fileBuffer = readFileSync(this.bufferFilePath)
|
||||
const str = fileBuffer.toString()
|
||||
const lines = str.split("\n")
|
||||
return lines.length - 1
|
||||
} catch (e) {
|
||||
if (isTruthy(MEDUSA_TELEMETRY_VERBOSE)) {
|
||||
console.error("Failed to get outbox count", e)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
async flushFile(filePath, flushOperation) {
|
||||
const now = `${Date.now()}-${process.pid}`
|
||||
let success = false
|
||||
let contents = ``
|
||||
try {
|
||||
if (!existsSync(filePath)) {
|
||||
return true
|
||||
}
|
||||
// Unique temporary file name across multiple concurrent Medusa instances
|
||||
const newPath = `${this.bufferFilePath}-${now}`
|
||||
renameSync(filePath, newPath)
|
||||
contents = readFileSync(newPath, `utf8`)
|
||||
unlinkSync(newPath)
|
||||
|
||||
// There is still a chance process dies while sending data and some events are lost
|
||||
// This will be ok for now, however
|
||||
success = await flushOperation(contents)
|
||||
} catch (e) {
|
||||
if (isTruthy(MEDUSA_TELEMETRY_VERBOSE)) {
|
||||
console.error("Failed to perform file flush", e)
|
||||
}
|
||||
} finally {
|
||||
// if sending fails, we write the data back to the log
|
||||
if (!success) {
|
||||
if (isTruthy(MEDUSA_TELEMETRY_VERBOSE)) {
|
||||
console.error(
|
||||
"File flush did not succeed - writing back to file",
|
||||
success
|
||||
)
|
||||
}
|
||||
this.appendToBuffer(contents)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async startFlushEvents(flushOperation) {
|
||||
try {
|
||||
await this.flushFile(this.bufferFilePath, flushOperation)
|
||||
const files = readdirSync(this.baseDir)
|
||||
const filtered = files.filter(p => p.startsWith(`events.json`))
|
||||
for (const file of filtered) {
|
||||
await this.flushFile(path.join(this.baseDir, file), flushOperation)
|
||||
}
|
||||
return true
|
||||
} catch (e) {
|
||||
if (isTruthy(MEDUSA_TELEMETRY_VERBOSE)) {
|
||||
console.error("Failed to perform flush", e)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export default Outbox
|
||||
@@ -0,0 +1,10 @@
|
||||
import TelemetryDispatcher from "./telemetry-dispatcher"
|
||||
|
||||
const MEDUSA_TELEMETRY_HOST = process.env.MEDUSA_TELEMETRY_HOST || ""
|
||||
const MEDUSA_TELEMETRY_PATH = process.env.MEDUSA_TELEMETRY_PATH || ""
|
||||
|
||||
const dispatcher = new TelemetryDispatcher({
|
||||
host: MEDUSA_TELEMETRY_HOST,
|
||||
path: MEDUSA_TELEMETRY_PATH,
|
||||
})
|
||||
dispatcher.dispatch()
|
||||
@@ -0,0 +1,25 @@
|
||||
import boxen from "boxen"
|
||||
|
||||
const defaultConfig = {
|
||||
padding: 1,
|
||||
borderColor: `blue`,
|
||||
borderStyle: `double`,
|
||||
}
|
||||
|
||||
const defaultMessage =
|
||||
`Medusa collects anonymous usage analytics\n` +
|
||||
`to help improve Medusa for all users.\n` +
|
||||
`\n` +
|
||||
`If you'd like to opt-out, you can use \`medusa telemetry --disable\`\n`
|
||||
|
||||
/**
|
||||
* Analytics notice for the end-user
|
||||
*/
|
||||
function showAnalyticsNotification(
|
||||
config = defaultConfig,
|
||||
message = defaultMessage
|
||||
) {
|
||||
console.log(boxen(message, config))
|
||||
}
|
||||
|
||||
export default showAnalyticsNotification
|
||||
@@ -0,0 +1,115 @@
|
||||
import removeSlash from "remove-trailing-slash"
|
||||
import axios from "axios"
|
||||
import axiosRetry from "axios-retry"
|
||||
|
||||
import showAnalyticsNotification from "./show-notification"
|
||||
import Store from "../store"
|
||||
import isTruthy from "./is-truthy"
|
||||
|
||||
const MEDUSA_TELEMETRY_VERBOSE = process.env.MEDUSA_TELEMETRY_VERBOSE || false
|
||||
|
||||
class TelemetryDispatcher {
|
||||
constructor(options) {
|
||||
this.store_ = new Store()
|
||||
|
||||
this.host = removeSlash(
|
||||
options.host || "https://telemetry.medusa-commerce.com"
|
||||
)
|
||||
this.path = removeSlash(options.path || "/batch")
|
||||
|
||||
let axiosInstance = options.axiosInstance
|
||||
if (!axiosInstance) {
|
||||
axiosInstance = axios.create()
|
||||
}
|
||||
this.axiosInstance = axiosInstance
|
||||
|
||||
this.timeout = options.timeout || false
|
||||
this.flushed = false
|
||||
|
||||
axiosRetry(this.axiosInstance, {
|
||||
retries: 3,
|
||||
retryDelay: axiosRetry.exponentialDelay,
|
||||
retryCondition: this.isErrorRetryable_,
|
||||
})
|
||||
}
|
||||
|
||||
isTrackingEnabled() {
|
||||
// Cache the result
|
||||
if (this.trackingEnabled !== undefined) {
|
||||
return this.trackingEnabled
|
||||
}
|
||||
let enabled = this.store_.getConfig(`telemetry.enabled`)
|
||||
if (enabled === undefined || enabled === null) {
|
||||
showAnalyticsNotification()
|
||||
enabled = true
|
||||
this.store_.setConfig(`telemetry.enabled`, enabled)
|
||||
}
|
||||
this.trackingEnabled = enabled
|
||||
return enabled
|
||||
}
|
||||
|
||||
async dispatch() {
|
||||
if (!this.isTrackingEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.store_.flushEvents(async events => {
|
||||
if (!events.length) {
|
||||
if (isTruthy(MEDUSA_TELEMETRY_VERBOSE)) {
|
||||
console.log("No events to POST - skipping")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const data = {
|
||||
batch: events,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
|
||||
const req = {
|
||||
headers: {},
|
||||
}
|
||||
|
||||
return await this.axiosInstance
|
||||
.post(`${this.host}${this.path}`, data, req)
|
||||
.then(() => {
|
||||
if (isTruthy(MEDUSA_TELEMETRY_VERBOSE)) {
|
||||
console.log("POSTing batch succeeded")
|
||||
}
|
||||
return true
|
||||
})
|
||||
.catch(e => {
|
||||
if (isTruthy(MEDUSA_TELEMETRY_VERBOSE)) {
|
||||
console.error("Failed to POST event batch", e)
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
isErrorRetryable_(error) {
|
||||
// Retry Network Errors.
|
||||
if (axiosRetry.isNetworkError(error)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!error.response) {
|
||||
// Cannot determine if the request can be retried
|
||||
return false
|
||||
}
|
||||
|
||||
// Retry Server Errors (5xx).
|
||||
if (error.response.status >= 500 && error.response.status <= 599) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Retry if rate limited.
|
||||
if (error.response.status === 429) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export default TelemetryDispatcher
|
||||
Reference in New Issue
Block a user