packages/exception-reporting/lib/reporter.js

Summary

Maintainability
D
2 days
Test Coverage
/** @babel */

import os from 'os';
import stackTrace from 'stack-trace';
import fs from 'fs-plus';
import path from 'path';

const API_KEY = '7ddca14cb60cbd1cd12d1b252473b076';
const LIB_VERSION = require('../package.json')['version'];
const StackTraceCache = new WeakMap();

export default class Reporter {
  constructor(params = {}) {
    this.request = params.request || window.fetch;
    this.alwaysReport = params.hasOwnProperty('alwaysReport')
      ? params.alwaysReport
      : false;
    this.reportPreviousErrors = params.hasOwnProperty('reportPreviousErrors')
      ? params.reportPreviousErrors
      : true;
    this.resourcePath = this.normalizePath(
      params.resourcePath || process.resourcesPath
    );
    this.reportedErrors = [];
    this.reportedAssertionFailures = [];
  }

  buildNotificationJSON(error, params) {
    return {
      apiKey: API_KEY,
      notifier: {
        name: 'Atom',
        version: LIB_VERSION,
        url: 'https://www.atom.io'
      },
      events: [
        {
          payloadVersion: '2',
          exceptions: [this.buildExceptionJSON(error, params.projectRoot)],
          severity: params.severity,
          user: {
            id: params.userId
          },
          app: {
            version: params.appVersion,
            releaseStage: params.releaseStage
          },
          device: {
            osVersion: params.osVersion
          },
          metaData: error.metadata
        }
      ]
    };
  }

  buildExceptionJSON(error, projectRoot) {
    return {
      errorClass: error.constructor.name,
      message: error.message,
      stacktrace: this.buildStackTraceJSON(error, projectRoot)
    };
  }

  buildStackTraceJSON(error, projectRoot) {
    return this.parseStackTrace(error).map(callSite => {
      return {
        file: this.scrubPath(callSite.getFileName()),
        method:
          callSite.getMethodName() || callSite.getFunctionName() || 'none',
        lineNumber: callSite.getLineNumber(),
        columnNumber: callSite.getColumnNumber(),
        inProject: !/node_modules/.test(callSite.getFileName())
      };
    });
  }

  normalizePath(pathToNormalize) {
    return pathToNormalize
      .replace('file:///', '') // Sometimes it's a uri
      .replace(/\\/g, '/'); // Unify path separators across Win/macOS/Linux
  }

  scrubPath(pathToScrub) {
    const absolutePath = this.normalizePath(pathToScrub);

    if (this.isBundledFile(absolutePath)) {
      return this.normalizePath(path.relative(this.resourcePath, absolutePath));
    } else {
      return absolutePath
        .replace(this.normalizePath(fs.getHomeDirectory()), '~') // Remove users home dir
        .replace(/.*(\/packages\/.*)/, '$1'); // Remove everything before app.asar or packages
    }
  }

  getDefaultNotificationParams() {
    return {
      userId: atom.config.get('exception-reporting.userId'),
      appVersion: atom.getVersion(),
      releaseStage: this.getReleaseChannel(atom.getVersion()),
      projectRoot: atom.getLoadSettings().resourcePath,
      osVersion: `${os.platform()}-${os.arch()}-${os.release()}`
    };
  }

  getReleaseChannel(version) {
    return version.indexOf('beta') > -1
      ? 'beta'
      : version.indexOf('dev') > -1
      ? 'dev'
      : 'stable';
  }

  performRequest(json) {
    this.request.call(null, 'https://notify.bugsnag.com', {
      method: 'POST',
      headers: new Headers({ 'Content-Type': 'application/json' }),
      body: JSON.stringify(json)
    });
  }

  shouldReport(error) {
    if (this.alwaysReport) return true; // Used in specs
    if (atom.config.get('core.telemetryConsent') !== 'limited') return false;
    if (atom.inDevMode()) return false;

    const topFrame = this.parseStackTrace(error)[0];
    const fileName = topFrame ? topFrame.getFileName() : null;
    return (
      fileName &&
      (this.isBundledFile(fileName) || this.isTeletypeFile(fileName))
    );
  }

  parseStackTrace(error) {
    let callSites = StackTraceCache.get(error);
    if (callSites) {
      return callSites;
    } else {
      callSites = stackTrace.parse(error);
      StackTraceCache.set(error, callSites);
      return callSites;
    }
  }

  requestPrivateMetadataConsent(error, message, reportFn) {
    let notification, dismissSubscription;

    function reportWithoutPrivateMetadata() {
      if (dismissSubscription) {
        dismissSubscription.dispose();
      }
      delete error.privateMetadata;
      delete error.privateMetadataDescription;
      reportFn(error);
      if (notification) {
        notification.dismiss();
      }
    }

    function reportWithPrivateMetadata() {
      if (error.metadata == null) {
        error.metadata = {};
      }
      for (let key in error.privateMetadata) {
        let value = error.privateMetadata[key];
        error.metadata[key] = value;
      }
      reportWithoutPrivateMetadata();
    }

    const name = error.privateMetadataRequestName;
    if (name != null) {
      if (localStorage.getItem(`private-metadata-request:${name}`)) {
        return reportWithoutPrivateMetadata(error);
      } else {
        localStorage.setItem(`private-metadata-request:${name}`, true);
      }
    }

    notification = atom.notifications.addInfo(message, {
      detail: error.privateMetadataDescription,
      description:
        'Are you willing to submit this information to a private server for debugging purposes?',
      dismissable: true,
      buttons: [
        {
          text: 'No',
          onDidClick: reportWithoutPrivateMetadata
        },
        {
          text: 'Yes, Submit for Debugging',
          onDidClick: reportWithPrivateMetadata
        }
      ]
    });

    dismissSubscription = notification.onDidDismiss(
      reportWithoutPrivateMetadata
    );
  }

  addPackageMetadata(error) {
    let activePackages = atom.packages.getActivePackages();
    const availablePackagePaths = atom.packages.getPackageDirPaths();
    if (activePackages.length > 0) {
      let userPackages = {};
      let bundledPackages = {};
      for (let pack of atom.packages.getActivePackages()) {
        if (availablePackagePaths.includes(path.dirname(pack.path))) {
          userPackages[pack.name] = pack.metadata.version;
        } else {
          bundledPackages[pack.name] = pack.metadata.version;
        }
      }

      if (error.metadata == null) {
        error.metadata = {};
      }
      error.metadata.bundledPackages = bundledPackages;
      error.metadata.userPackages = userPackages;
    }
  }

  addPreviousErrorsMetadata(error) {
    if (!this.reportPreviousErrors) return;
    if (!error.metadata) error.metadata = {};
    error.metadata.previousErrors = this.reportedErrors.map(
      error => error.message
    );
    error.metadata.previousAssertionFailures = this.reportedAssertionFailures.map(
      error => error.message
    );
  }

  reportUncaughtException(error) {
    if (!this.shouldReport(error)) return;

    this.addPackageMetadata(error);
    this.addPreviousErrorsMetadata(error);

    if (
      error.privateMetadata != null &&
      error.privateMetadataDescription != null
    ) {
      this.requestPrivateMetadataConsent(
        error,
        'The Atom team would like to collect the following information to resolve this error:',
        error => this.reportUncaughtException(error)
      );
      return;
    }

    let params = this.getDefaultNotificationParams();
    params.severity = 'error';
    this.performRequest(this.buildNotificationJSON(error, params));
    this.reportedErrors.push(error);
  }

  reportFailedAssertion(error) {
    if (!this.shouldReport(error)) return;

    this.addPackageMetadata(error);
    this.addPreviousErrorsMetadata(error);

    if (
      error.privateMetadata != null &&
      error.privateMetadataDescription != null
    ) {
      this.requestPrivateMetadataConsent(
        error,
        'The Atom team would like to collect some information to resolve an unexpected condition:',
        error => this.reportFailedAssertion(error)
      );
      return;
    }

    let params = this.getDefaultNotificationParams();
    params.severity = 'warning';
    this.performRequest(this.buildNotificationJSON(error, params));
    this.reportedAssertionFailures.push(error);
  }

  // Used in specs
  setRequestFunction(requestFunction) {
    this.request = requestFunction;
  }

  isBundledFile(fileName) {
    return this.normalizePath(fileName).indexOf(this.resourcePath) === 0;
  }

  isTeletypeFile(fileName) {
    const teletypePath = atom.packages.resolvePackagePath('teletype');
    return (
      teletypePath && this.normalizePath(fileName).indexOf(teletypePath) === 0
    );
  }
}

Reporter.API_KEY = API_KEY;
Reporter.LIB_VERSION = LIB_VERSION;