@stoplight/reporter

View the changelog: Releases

Usage no npm install needed!

<script type="module">
  import stoplightReporter from 'https://cdn.skypack.dev/@stoplight/reporter';
</script>

README

@stoplight/reporter

Installation

Supported in modern browsers and node.

# latest stable
yarn add @stoplight/reporter

The Why?

If you reached this page, it is very likely that someone told you "we've got @stoplight/reporter package, use it instead, please.". You are likely to be wondering what on earth this package is, and what kind of problem it aims to solve. Although it may redundant, keep in mind we have plenty of projects, some of which share similar needs. For instance, there are Studio Desktop, Studio Web and Ninja (Platform V2). They all integrate with the same set of services. In normal circumstances, you would need to duplicate certain amount of code, and given there are separate teams actively working on the these projects, the API would most likely differ here and there causing disruptions to other engineers responsible for implementing tracking. Moreover, since we expose custom services, the payload needs to be consistent. Having that in one place makes things easier for us. Furthermore, swapping services is easier, because we have all of them in a single spot.

Usage

Before we move to actual integrations, let's start with basics. We can distinguish two kinds of consumers:

  • libraries, i.e. graphite
  • end applications, i.e. studio

What are getReporter and setReporter?

getReporter is particularly useful when it comes to libraries. This is the only way for them to access the reporter instance and actually use it. Apart from that, in the vast majority of cases, it is simply undesired to have more than one reporter attached. You would not like to send the same unhandled error twice, right? This is what Sentry does, for instance. It does not let you attaches itself twice, otherwise the reports would be duplicate. These methods aim to prevent you from doing that. How is it accomplished? More on this below.

setReporter takes 2 arguments, one of which is optional and defaults to global. export declare function setReporter(reporterInstance: IReportingAPI, scope?: object): IReportingAPI;

What it does under the hood is setting a non-enumerable, non-writable and non-configurable property on given scope with the reporter as its value. Why is setting a non-writable and non-configurable property crucial? It makes sure no one can overwrite it in any way. Moreover, the implementation uses Object.defineProperty that throws if the property already exists and is non-configurable.

You can easily check it out by doing

setReporter(myReporter);
setReporter(myOtherReporter); // throws.

Hm, hold on. Why do we actually need to set anything on global scope? Although ES Modules are meant to be executed once, we cannot naively assume this will be always the case, for a number of reasons. While, the code is usually processed once, there is a way to load it once again, i.e. by clearing require.cache. Moreover, bundling process is error-prone as well, since code might be inlined for some reason by a certain package, and Webpack or Rollup will be unaware of that and load it once again. In other words - it's just more bulletproof.

Alright, I see. What's the purpose of scope then? It's an escape trap if you do need to set another instance of reporter, for whatever reason.

setReporter(myReporter);
const myScope = {};
setReporter(myOtherReporter, myScope); // does not throw

Cool.

getReporter export declare function getReporter(scope?: object): Optional<IReportingAPI>;

This does not need that much of an explanation, as it basically retrieves the previously set reporter. If takes the scope as an optional argument, useful if you have a reporter registered on a different scope.

Having said all of that, using getReporter before setting a new reporter is optional, the final call belongs to yourself. If your resolver is very custom, you might be happy when things fail,as this will clearly indicate someone registered another reporter that may conflict with yours. When you do have a custom implementation, tailored to your needs, just set it on a custom scope. Just don't forget to pass it that scope to getReporter later on.

IReportingAPI

export declare type Payload = Dictionary<any, string>;
export interface IReportingAPI {
    error(ex: Error | IDetailedError): void;
    error(message: string, payload?: Payload): void;
    warn(message: string, payload?: Payload): void;
    info(message: string, payload?: Payload): void;
    log(message: string, payload?: Payload): void;
    debug(message: string, payload?: Payload): void;
    time(label: string): void;
    timeEnd(label: string, payload?: Payload): void;
}

As you may see, it is a kind of subset of Console API, which the majority of engineers should be familiar with.

DetailedError

This is a special abstract Error class that extends the native Error one. It has two following advantages over the native Error:

  • it is serializable (crucial for Studio, where we serialize certain errors back and forth between the renderer thread and the worker)
  • extra property that is then attached to Sentry extras when the error gets sent
Example
import { GraphiteError as GraphiteErrorType } from '@stoplight/graphite';
import { DetailedError } from '@stoplight/reporter/exceptions';

export type GraphiteErrorPayload = Pick<GraphiteErrorType, 'code' | 'nodeId' | 'trace' | 'data'>;

export class GraphiteError extends DetailedError<GraphiteErrorPayload> {
  public name = 'GraphiteError';
  public extra: GraphiteErrorPayload;

  constructor(error: GraphiteErrorType) {
    super(error.message);

    this.extra = { // this is included in Sentry whenever you throw GraphiteError
      code: error.code,
      nodeId: error.nodeId,
      data: error.data,
      trace: error.trace,
    };
  }
}

Extra methods

  • addPayloadBuilder - the builder method is called whenever a particular individual payload is about to be sent. This is very useful if you want to attach information that is specific to a given payload at the execution time. It's been inspired by beaver-logger, therefore you can check out theirs documentation either.

  • addMetaBuilder - the builder method is called:

    • once - in case of Sentry, it's supposed to clear the previously set Sentry's scope and set configure a new one which is a result of the data returned by builder,
    • when the logs are about to be flushed - in case of logger (see docs below for more info)

It's been inspired by beaver-logger, therefore you can check out theirs documentation either.

Both methods are hooked up for both Sentry and the logger instance (if it implements such). Moreover, Amplitude makes use of them as well.

It's not required for a particular Reporter to implement addPayloadBuilder and/or addMetaBuilder. They are both totally optional and serve mostly to make attaching general state information easier.

Here is an actual real-life example of usage of addPayloadBuilder

// add some global properties to each logged event
if ('addPayloadBuilder' in Reporter) {
  Reporter.addPayloadBuilder(() => {
    const payload: {
      sidebar_tree?: string;
      active_panels?: ModeId[];
      num_active_panels?: number;
    } = {};

    const studioStore = this.activeProjectStore?.studioStore;
    const graphStore = this.activeProjectStore?.graphStore;
    if (studioStore && graphStore?.state === 'activated') {
      payload.sidebar_tree = studioStore.state === 'activated' ? studioStore.uiStore.activeSidebarTree : 'na';
      payload.active_panels = studioStore.uiStore?.activeLayout?.activeModeIds || [];
      payload.num_active_panels = payload.active_panels.length;
    }

    return payload;
  });
}

Each entry in Sentry, Kibana as well as other tools (provided that it's properly integrated) will have that extra information attached to each entry / event.

This is how Sentry processes the data:

sentry-additional

Another example of addMetaBuilder

// track some global data on all logged events
if ('addMetaBuilder' in Reporter) {
  Reporter.addMetaBuilder(() => {
    const payload: any = {
      is_studio: true,
      is_desktop: process.env.RUN_CONTEXT === 'desktop',
      user_id: this.userStore.authorizedUser ? this.userStore.authorizedUser.id : null,
      // DEPRECATED: replaced by device_id, can remove in a few months
      install_id: deviceId,
    };

    const studioStore = this.activeProjectStore && this.activeProjectStore.studioStore;
    if (studioStore) {
      payload.is_git = studioStore.state === 'activated' && studioStore.gitStore.isGitRepo;
    }

    return payload;
  });
}

This is how Sentry processes the data:

sentry-additional

Browser

In the main entry-point of your project

import { setReporter } from '@stoplight/reporter';
import { BrowserReporter } from '@stoplight/reporter/integrations/browser';

setReporter(
  new BrowserReporter({
    loggerOptions: {
      url: '/endpoint-url',
      // any other valid beaver-logger option
    },
    sentryOptions: {
      environment: 'production',
      // any other valid Sentry option
    },
  }),
);

In every other place

import { getReporter } from '@stoplight/reporter';

const reporter = getReporter();

reporter.error('woops');

Contributing

  1. Clone repo.
  2. Create / checkout feature/{name}, chore/{name}, or fix/{name} branch.
  3. Install deps: yarn.
  4. Make your changes.
  5. Run tests: yarn test.prod.
  6. Stage relevant files to git.
  7. Commit: yarn commit. NOTE: Commits that don't follow the conventional format will be rejected. yarn commit creates this format for you, or you can put it together manually and then do a regular git commit.
  8. Push: git push.
  9. Open PR targeting the master branch.