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:
Sebastian Rindom
2021-08-05 12:23:05 +02:00
committed by GitHub
parent f07cc0fa40
commit cfe19f7f9d
29 changed files with 6509 additions and 58 deletions
@@ -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