# Official Airbrake Notifiers for JavaScript
[](https://github.com/airbrake/airbrake-js/actions?query=branch%3Amaster)
[](https://www.npmjs.com/package/@airbrake/browser)
Please choose one of the following packages:
[@airbrake/browser](packages/browser) for web browsers.
[@airbrake/node](packages/node) for Node.js.
================================================
FILE: lerna.json
================================================
{
"version": "2.1.9",
"npmClient": "yarn",
"useWorkspaces": true
}
================================================
FILE: package.json
================================================
{
"name": "airbrake",
"private": true,
"devDependencies": {
"concurrently": "^7.6.0",
"lerna": "^6.0.3",
"prettier": "^2.0.2",
"tslint": "^6.1.0",
"tslint-config-prettier": "^1.18.0",
"tslint-plugin-prettier": "^2.3.0",
"typescript": "^4.0.2"
},
"scripts": {
"build": "lerna run build",
"build:watch": "lerna run build:watch --parallel",
"clean": "lerna run clean",
"lint": "lerna run lint",
"test": "lerna run test"
},
"workspaces": [
"packages/*"
]
}
================================================
FILE: packages/browser/LICENSE
================================================
MIT License
Copyright (c) 2020 Airbrake Technologies, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: packages/browser/README.md
================================================
# Official Airbrake Notifier for Browsers
[](https://github.com/airbrake/airbrake-js/actions?query=branch%3Amaster)
[](https://www.npmjs.com/package/@airbrake/browser)
[](https://www.npmjs.com/package/@airbrake/browser)
[](https://www.npmjs.com/package/@airbrake/browser)
The official Airbrake notifier for capturing JavaScript errors in web browsers
and reporting them to [Airbrake](http://airbrake.io). If you're looking for
Node.js support, there is a
[separate package](https://github.com/airbrake/airbrake-js/tree/master/packages/node).
## Installation
Using yarn:
```sh
yarn add @airbrake/browser
```
Using npm:
```sh
npm install @airbrake/browser
```
Using a `
```
Using a `
```
## Basic Usage
First, initialize the notifier with the project ID and project key taken from
[Airbrake](https://airbrake.io). To find your `project_id` and `project_key`
navigate to your project's _Settings_ and copy the values from the right
sidebar:
![][project-idkey]
```js
import { Notifier } from '@airbrake/browser';
const airbrake = new Notifier({
projectId: 1,
projectKey: 'REPLACE_ME',
environment: 'production',
});
```
Then, you can send a textual message to Airbrake:
```js
let promise = airbrake.notify(`user id=${user_id} not found`);
promise.then((notice) => {
if (notice.id) {
console.log('notice id', notice.id);
} else {
console.log('notify failed', notice.error);
}
});
```
or report errors directly:
```js
try {
throw new Error('Hello from Airbrake!');
} catch (err) {
airbrake.notify(err);
}
```
Alternatively, you can wrap any code which may throw errors using the `wrap`
method:
```js
let startApp = () => {
throw new Error('Hello from Airbrake!');
};
startApp = airbrake.wrap(startApp);
// Any exceptions thrown in startApp will be reported to Airbrake.
startApp();
```
or use the `call` shortcut:
```js
let startApp = () => {
throw new Error('Hello from Airbrake!');
};
airbrake.call(startApp);
```
## Example configurations
- [AngularJS](examples/angularjs)
- [Angular](examples/angular)
- [Legacy](examples/legacy)
- [Rails](examples/rails)
- [React](examples/react)
- [Redux](examples/redux)
- [Vue.js](examples/vuejs)
## Advanced Usage
### Notice Annotations
It's possible to annotate error notices with all sorts of useful information at
the time they're captured by supplying it in the object being reported.
```js
try {
startApp();
} catch (err) {
airbrake.notify({
error: err,
context: { component: 'bootstrap' },
environment: { env1: 'value' },
params: { param1: 'value' },
session: { session1: 'value' },
});
}
```
### Severity
[Severity](https://airbrake.io/docs/airbrake-faq/what-is-severity/) allows
categorizing how severe an error is. By default, it's set to `error`. To
redefine severity, simply overwrite `context/severity` of a notice object:
```js
airbrake.notify({
error: err,
context: { severity: 'warning' },
});
```
### Filtering errors
There may be some errors thrown in your application that you're not interested
in sending to Airbrake, such as errors thrown by 3rd-party libraries, or by
browser extensions run by your users.
The Airbrake notifier makes it simple to ignore this chaff while still
processing legitimate errors. Add filters to the notifier by providing filter
functions to `addFilter`.
`addFilter` accepts the entire
[error notice](https://airbrake.io/docs/api/#create-notice-v3) to be sent to
Airbrake and provides access to the `context`, `environment`, `params`,
and `session` properties. It also includes the single-element `errors` array
with its `backtrace` property and associated backtrace lines.
The return value of the filter function determines whether or not the error
notice will be submitted.
- If `null` is returned, the notice is ignored.
- Otherwise, the returned notice will be submitted.
An error notice must pass all provided filters to be submitted.
In the following example all errors triggered by admins will be ignored:
```js
airbrake.addFilter((notice) => {
if (notice.params.admin) {
// Ignore errors from admin sessions.
return null;
}
return notice;
});
```
Filters can be also used to modify notice payload, e.g. to set the environment
and application version:
```js
airbrake.addFilter((notice) => {
notice.context.environment = 'production';
notice.context.version = '1.2.3';
return notice;
});
```
### Filtering keys
#### keysBlocklist
With the `keysBlocklist` option, you can specify a list of keys containing
sensitive information that must be filtered out:
```js
const airbrake = new Notifier({
// ...
keysBlocklist: [
'password', // exact match
/secret/, // regexp match
],
});
```
#### keysAllowlist
With the `keysAllowlist` option, you can specify a list of keys that should
_not_ be filtered. All other keys will be substituted with the `[Filtered]`
label.
```js
const airbrake = new Notifier({
// ...
keysAllowlist: [
'email', // exact match
/name/, // regexp match
],
});
```
### Source maps
Airbrake supports using private and public source maps. Check out our docs for
more info:
- [Private source maps](https://airbrake.io/docs/features/private-sourcemaps/)
- [Public source maps](https://airbrake.io/docs/features/public-sourcemaps/)
### Instrumentation
#### console
`@airbrake/browser` automatically instruments `console.log` function calls in
order to collect logs and send them with the first error. You can disable that
behavior using the `instrumentation` option:
```js
const airbrake = new Notifier({
// ...
instrumentation: {
console: false,
},
});
```
#### fetch
Instruments [`fetch`][fetch] calls and sends performance statistics to Airbrake.
You can disable that behavior using the `instrumentation` option:
```js
const airbrake = new Notifier({
// ...
instrumentation: {
fetch: false,
},
});
```
#### onerror
Reports the errors occurring in the Window's [error event][onerror]. You can
disable that behavior using the `instrumentation` option:
```js
const airbrake = new Notifier({
// ...
instrumentation: {
onerror: false,
},
});
```
#### history
Records the history of events that led to the error and sends it to Airbrake.
You can disable that behavior using the `instrumentation` option:
```js
const airbrake = new Notifier({
// ...
instrumentation: {
history: false,
},
});
```
#### xhr
Instruments [XMLHttpRequest][xhr] requests and sends performance statistics to
Airbrake. You can disable that behavior using the `instrumentation` option:
```js
const airbrake = new Notifier({
// ...
instrumentation: {
xhr: false,
},
});
```
#### unhandledrejection
Instruments the [unhandledrejection][unhandledrejection] event and sends
performance statistics to Airbrake. You can disable that behavior using the
`instrumentation` option:
```js
const airbrake = new Notifier({
// ...
instrumentation: {
unhandledrejection: false,
},
});
```
### APM
#### Routes
```js
import { Notifier } from '@airbrake/browser';
const airbrake = new Notifier({
projectId: 1,
projectKey: 'REPLACE_ME',
environment: 'production',
});
const routeMetric = this.airbrake.routes.start(
'GET', // HTTP method name
'/abc', // Route name
200, // Status code
'application/json' // Content-Type header
);
this.airbrake.routes.notify(routeMetric);
```
#### Queries
```js
import { Notifier } from '@airbrake/browser';
const airbrake = new Notifier({
projectId: 1,
projectKey: 'REPLACE_ME',
environment: 'production',
});
const queryInfo = this.airbrake.queries.start('SELECT * FROM things;');
queryInfo.file = 'file.js';
queryInfo.func = 'callerFunc';
queryInfo.line = 21;
queryInfo.method = 'GET';
queryInfo.route = '/abc';
this.airbrake.queries.notify(queryInfo);
```
#### Queues
```js
import { Notifier } from '@airbrake/browser';
const airbrake = new Notifier({
projectId: 1,
projectKey: 'REPLACE_ME',
environment: 'production',
});
const queueInfo = this.airbrake.queues.start('FooWorker');
this.airbrake.queues.notify(queueInfo);
```
[project-idkey]: https://s3.amazonaws.com/airbrake-github-assets/airbrake-js/project-id-key.png
[fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
[onerror]: https://developer.mozilla.org/en-US/docs/Web/API/Window/error_event
[xhr]: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest
[unhandledrejection]: https://developer.mozilla.org/en-US/docs/Web/API/Window/unhandledrejection_event
================================================
FILE: packages/browser/babel.config.js
================================================
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
};
================================================
FILE: packages/browser/examples/angular/README.md
================================================
# Usage with Angular
### Create an error handler
The first step is to create an error handler with a `Notifier`
initialized with your `projectId` and `projectKey`. In this example the
handler will be in a file called `airbrake-error-handler.ts`.
```ts
// src/app/airbrake-error-handler.ts
import { ErrorHandler } from '@angular/core';
import { Notifier } from '@airbrake/browser';
export class AirbrakeErrorHandler implements ErrorHandler {
airbrake: Notifier;
constructor() {
this.airbrake = new Notifier({
projectId: 1,
projectKey: 'FIXME',
environment: 'production'
});
}
handleError(error: any): void {
this.airbrake.notify(error);
}
}
```
### Add the error handler to your `AppModule`
The last step is adding the `AirbrakeErrorHandler` to your `AppModule`, then
your app will be ready to report errors to Airbrake.
```ts
// src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule, ErrorHandler } from '@angular/core';
import { AppComponent } from './app.component';
import { AirbrakeErrorHandler } from './airbrake-error-handler';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule
],
providers: [{provide: ErrorHandler, useClass: AirbrakeErrorHandler}],
bootstrap: [AppComponent]
})
export class AppModule { }
```
To test that Airbrake has been installed correctly in your Angular project,
just open up the JavaScript console in your internet browser and paste in:
```js
window.onerror("TestError: This is a test", "path/to/file.js", 123);
```
================================================
FILE: packages/browser/examples/angularjs/README.md
================================================
# Usage with AngularJS
Integration with AngularJS is as simple as adding an [$exceptionHandler][1]:
```js
// app.js
const module = angular.module('app', []);
module.factory('$exceptionHandler', function ($log) {
const airbrake = new Airbrake.Notifier({
projectId: 1, // Airbrake project id
projectKey: 'FIXME', // Airbrake project API key
});
airbrake.addFilter(function (notice) {
notice.context.environment = 'production';
return notice;
});
return function (exception, cause) {
$log.error(exception);
airbrake.notify({ error: exception, params: { angular_cause: cause } });
};
});
module.controller('HelloWorldCtrl', function ($scope) {
throw new Error('Uh oh, something happened');
$scope.message = 'Hello World';
});
```
```html
Hello World
{{message}}
```
[1]: https://docs.angularjs.org/api/ng/service/$exceptionHandler
================================================
FILE: packages/browser/examples/extjs/README.md
================================================
# Usage with Ext JS
### Install the `@airbrake/browser` package
```sh
npm i @airbrake/browser
```
### Make the package available to the ExtJS framework
Inside the `index.js` file located at the root of your project, require the
package and set it as an Ext global property.
```js
// index.js
// To avoid naming conflicts with existing ExtJS properties, prepend your
// package name with x
// https://docs.sencha.com/extjs/7.4.0/guides/using_systems/using_npm/adding_npm_packages.html
Ext.xAirbrake = require('@airbrake/browser');
```
### Instantiate the notifier
Also in `index.js`, create a new notifier instance with your `projectId` and
`projectKey`.
```js
new Ext.xAirbrake.Notifier({
projectId: 1,
projectKey: 'FIXME'
});
```
Airbrake will now automatically report any unhandled exceptions. If you want to
send any errors manually, set the notifier instance to a variable and call
`.notify()` where needed.
================================================
FILE: packages/browser/examples/legacy/README.md
================================================
# Usage with legacy applications
This example loads @airbrake/browser using a `script` tag via the jsdelivr CDN.
Open `index.html` in your browser to start it.
================================================
FILE: packages/browser/examples/legacy/app.js
================================================
var airbrake = new Airbrake.Notifier({
projectId: 1,
projectKey: 'FIXME',
});
$(function() {
$('#send_error').click(function() {
try {
history.pushState({ foo: 'bar' }, 'Send error', 'send-error');
} catch (_) {}
var val = $('#error_text').val();
throw new Error(val);
});
});
try {
throw new Error('Hello from Airbrake!');
} catch (err) {
airbrake.notify(err).then(function(notice) {
if (notice.id) {
console.log('notice id:', notice.id);
} else {
console.log('notify failed:', notice.error);
}
});
}
throw new Error('uncaught error');
================================================
FILE: packages/browser/examples/legacy/index.html
================================================
Airbrake legacy example
================================================
FILE: packages/browser/examples/nextjs/.gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
.env*
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env.local
================================================
FILE: packages/browser/examples/nextjs/README.md
================================================
# Usage with Next.js
This is a sample application that can be found at
[Learn Next.js](https://nextjs.org/learn). It has been adapted to include
client-side and server-side error reporting with Airbrake.
To run the app, run `npm install` then `npm run dev`. The app will be available
at [http://localhost:3000](http://localhost:3000). Sample client-side
errors are triggered with a `Throw error` button on the [homepage](http://localhost:3000)
(`pages/index.js`). Sample server-side errors are triggered by visiting one of
the [blog post pages](http://localhost:3000/posts/ssg-ssr) (`posts/[id].js`).
## Client-side error reporting
To report client-side errors from a Next.js app, you'll need to set up and use an
[`ErrorBoundary` component](https://reactjs.org/docs/error-boundaries.html),
and initialize a `Notifier` with your `projectId` and `projectKey`.
```js
import React from 'react';
import { Notifier } from '@airbrake/browser';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
this.airbrake = new Notifier({
projectId: 1,
projectKey: 'FIXME'
});
}
componentDidCatch(error, info) {
// Display fallback UI
this.setState({ hasError: true });
// Send error to Airbrake
this.airbrake.notify({
error: error,
params: {info: info}
});
}
render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return
Something went wrong.
;
}
return this.props.children;
}
}
export default ErrorBoundary;
```
Then, you can use it as a regular component:
```html
```
## Server-side error reporting
To report server-side errors from a Next.js app, you'll need to [override the
default `Error` component](https://nextjs.org/docs/advanced-features/custom-error-page#more-advanced-error-page-customizing).
Define the file `pages/_error.js` and add the following code:
```js
function Error({ statusCode }) {
return (
{statusCode
? `An error ${statusCode} occurred on server`
: 'An error occurred on client'}
)
}
Error.getInitialProps = ({ res, err }) => {
if (typeof window === "undefined") {
const Airbrake = require('@airbrake/node')
const airbrake = new Airbrake.Notifier({
projectId: 1,
projectKey: 'FIXME'
});
if (err) {
airbrake.notify(err)
}
}
const statusCode = res ? res.statusCode : err ? err.statusCode : 404
return { statusCode }
}
export default Error
```
================================================
FILE: packages/browser/examples/nextjs/components/date.js
================================================
import { parseISO, format } from 'date-fns'
export default function Date({ dateString }) {
const date = parseISO(dateString)
return
}
================================================
FILE: packages/browser/examples/nextjs/components/error_boundary.js
================================================
import React from 'react';
import { Notifier } from '@airbrake/browser';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
this.airbrake = new Notifier({
projectId: process.env.NEXT_PUBLIC_AIRBRAKE_PROJECT_ID,
projectKey: process.env.NEXT_PUBLIC_AIRBRAKE_PROJECT_KEY,
environment: process.env.NEXT_PUBLIC_ENV
});
}
componentDidCatch(error, info) {
// Display fallback UI
this.setState({ hasError: true });
// Send error to Airbrake
this.airbrake.notify({
error: error,
params: {info: info}
});
}
render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return
Something went wrong.
;
}
return this.props.children;
}
}
export default ErrorBoundary;
================================================
FILE: packages/browser/examples/nextjs/components/layout.js
================================================
import Head from 'next/head'
import Image from 'next/image'
import styles from './layout.module.css'
import utilStyles from '../styles/utils.module.css'
import Link from 'next/link'
const name = 'Shu Uesugi'
export const siteTitle = 'Next.js Sample Website'
export default function Layout({ children, home }) {
return (
)
}
export async function getStaticProps() {
const allPostsData = getSortedPostsData()
return {
props: {
allPostsData
}
}
}
================================================
FILE: packages/browser/examples/nextjs/pages/posts/[id].js
================================================
import Layout from '../../components/layout'
import { getAllPostIds, getPostData } from '../../lib/posts'
import Head from 'next/head'
import Date from '../../components/date'
import utilStyles from '../../styles/utils.module.css'
export default function Post({ postData }) {
return (
{postData.title}
{postData.title}
)
}
export async function getStaticPaths() {
throw new Error("Server Error");
const paths = getAllPostIds()
return {
paths,
fallback: false
}
}
export async function getStaticProps({ params }) {
const postData = await getPostData(params.id)
return {
props: {
postData
}
}
}
================================================
FILE: packages/browser/examples/nextjs/posts/pre-rendering.md
================================================
---
title: "Two Forms of Pre-rendering"
date: "2020-01-01"
---
Next.js has two forms of pre-rendering: **Static Generation** and **Server-side Rendering**. The difference is in **when** it generates the HTML for a page.
- **Static Generation** is the pre-rendering method that generates the HTML at **build time**. The pre-rendered HTML is then _reused_ on each request.
- **Server-side Rendering** is the pre-rendering method that generates the HTML on **each request**.
Importantly, Next.js lets you **choose** which pre-rendering form to use for each page. You can create a "hybrid" Next.js app by using Static Generation for most pages and using Server-side Rendering for others.
================================================
FILE: packages/browser/examples/nextjs/posts/ssg-ssr.md
================================================
---
title: "When to Use Static Generation v.s. Server-side Rendering"
date: "2020-01-02"
---
We recommend using **Static Generation** (with and without data) whenever possible because your page can be built once and served by CDN, which makes it much faster than having a server render the page on every request.
You can use Static Generation for many types of pages, including:
- Marketing pages
- Blog posts
- E-commerce product listings
- Help and documentation
You should ask yourself: "Can I pre-render this page **ahead** of a user's request?" If the answer is yes, then you should choose Static Generation.
On the other hand, Static Generation is **not** a good idea if you cannot pre-render a page ahead of a user's request. Maybe your page shows frequently updated data, and the page content changes on every request.
In that case, you can use **Server-Side Rendering**. It will be slower, but the pre-rendered page will always be up-to-date. Or you can skip pre-rendering and use client-side JavaScript to populate data.
================================================
FILE: packages/browser/examples/nextjs/styles/global.css
================================================
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
line-height: 1.6;
font-size: 18px;
}
* {
box-sizing: border-box;
}
a {
color: #0070f3;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
img {
max-width: 100%;
display: block;
}
================================================
FILE: packages/browser/examples/nextjs/styles/utils.module.css
================================================
.heading2Xl {
font-size: 2.5rem;
line-height: 1.2;
font-weight: 800;
letter-spacing: -0.05rem;
margin: 1rem 0;
}
.headingXl {
font-size: 2rem;
line-height: 1.3;
font-weight: 800;
letter-spacing: -0.05rem;
margin: 1rem 0;
}
.headingLg {
font-size: 1.5rem;
line-height: 1.4;
margin: 1rem 0;
}
.headingMd {
font-size: 1.2rem;
line-height: 1.5;
}
.borderCircle {
border-radius: 9999px;
}
.colorInherit {
color: inherit;
}
.padding1px {
padding-top: 1px;
}
.list {
list-style: none;
padding: 0;
margin: 0;
}
.listItem {
margin: 0 0 1.25rem;
}
.lightText {
color: #666;
}
================================================
FILE: packages/browser/examples/rails/README.md
================================================
# Usage with Ruby on Rails
#### Option 1 - Asset pipeline
Copy the latest compiled UMD package bundle from
[https://unpkg.com/@airbrake/browser](https://unpkg.com/@airbrake/browser) to
`vendor/assets/javascripts/airbrake.js` in your project.
Then, add the following code to your Sprockets manifest:
```javascript
//= require airbrake
var airbrake = new Airbrake.Notifier({
projectId: 1,
projectKey: 'FIXME'
});
airbrake.addFilter(function(notice) {
notice.context.environment = "<%= Rails.env %>";
return notice;
});
try {
throw new Error('Hello from Airbrake!');
} catch (err) {
airbrake.notify(err).then(function(notice) {
if (notice.id) {
console.log('notice id:', notice.id);
} else {
console.log('notify failed:', notice.error);
}
});
}
```
#### Option 2 - Webpacker
Add `@airbrake/broswer` to your application.
```sh
yarn add @airbrake/browser
```
In your main application pack, import `@airbrake/browser` and configure the client.
```js
import { Notifier } from '@airbrake/browser';
const airbrake = new Notifier({
projectId: 1,
projectKey: 'FIXME'
});
airbrake.addFilter((notice) => {
notice.context.environment = process.env.RAILS_ENV;
return notice;
});
try {
throw new Error('Hello from Airbrake!');
} catch (err) {
airbrake.notify(err).then((notice) => {
if (notice.id) {
console.log('notice id:', notice.id);
} else {
console.log('notify failed:', notice.error);
}
});
}
```
You should now be able to capture JavaScript exceptions in your Ruby on Rails
application.
================================================
FILE: packages/browser/examples/react/README.md
================================================
# Usage with React
To report errors from a React app, you'll need to set up and use an
[`ErrorBoundary` component](https://reactjs.org/blog/2017/07/26/error-handling-in-react-16.html)
and initialize a `Notifier` with your `projectId` and `projectKey`.
```js
import React from 'react';
import { Notifier } from '@airbrake/browser';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
this.airbrake = new Notifier({
projectId: 1,
projectKey: 'FIXME'
});
}
componentDidCatch(error, info) {
// Display fallback UI
this.setState({ hasError: true });
// Send error to Airbrake
this.airbrake.notify({
error: error,
params: {info: info}
});
}
render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return
Something went wrong.
;
}
return this.props.children;
}
}
export default ErrorBoundary;
```
Then you can use it as a regular component:
```html
```
Read [Error Handling in React 16](https://reactjs.org/blog/2017/07/26/error-handling-in-react-16.html) for more details.
================================================
FILE: packages/browser/examples/redux/README.md
================================================
# Usage with Redux
#### 1. Add dependencies
```bash
npm install @airbrake/browser redux-airbrake --save
```
#### 2. Import dependency
```js
import { Notifier } from '@airbrake/browser';
import airbrakeMiddleware from 'redux-airbrake';
```
#### 3. Configure & add middleware
```js
const airbrake = new Notifier({
projectId: '******',
projectKey: '**************'
});
const errorMiddleware = airbrakeMiddleware(airbrake);
export const store = createStore(
rootReducer,
applyMiddleware(
errorMiddleware
)
);
export default store;
```
#### Adding notice annotations (optional)
It's possible to annotate error notices with all sorts of useful information at the time they're captured by supplying it in the object being reported.
```js
const errorMiddleware = airbrakeMiddleware(airbrake, {
noticeAnnotations: { context: { environment: window.ENV } }
});
```
#### Adding filters
Since an Airbrake instrace is passed to the middleware, you can simply add
filters to the instance as described here:
[https://github.com/airbrake/airbrake-js/tree/master/packages/browser#filtering-errors](https://github.com/airbrake/airbrake-js/tree/master/packages/browser#filtering-errors)
For full documentation, visit [redux-airbrake](https://github.com/alexcastillo/redux-airbrake) on GitHub.
================================================
FILE: packages/browser/examples/svelte/README.md
================================================
# Usage with Svelte
Integration with Svelte is as simple as adding `handleError` hooks (Server or Client):
- For server error handling, add `handleError` of `HandleServerError` type
- For handle error of client add `handleError` of `HandleClientError` type
## App configuration
```ts
// app.d.ts
// Add interface of Error
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
// and what to do when importing types
declare global {
namespace App {
interface Error {
message: unknown;
errorId: string;
}
}
}
export {};
```
## Server Hook
To establish an error handler for the server, use a `Notifier` with your `projectId` and `projectKey` as parameters. In this case, the handler will be located in the file `src/hooks.server.js`.
```js
// src/hooks.server.js
import crypto from 'crypto';
import { Notifier } from '@airbrake/browser';
var airbrake = new Notifier({
projectId: 1, // Airbrake project id
projectKey: 'FIXME', // Airbrake project API key
});
airbrake.addFilter(function (notice) {
notice.context.hooks = 'server';
return notice;
});
/** @type {import('@sveltejs/kit').HandleServerError} */
export function handleError({ error, event }) {
const errorId = crypto.randomUUID();
// example integration with https://airbrake.io/
airbrake.notify({
error: error,
params: { errorId: errorId, event: event },
});
return {
message: error,
errorId,
};
}
```
## Client Hook
To establish an error handler for the client, use a `Notifier` with your `projectId` and `projectKey` as parameters. In this case, the handler will be located in the file `src/hooks.client.js`.
```js
// src/hooks.client.js
import crypto from 'crypto';
import { Notifier } from '@airbrake/browser';
var airbrake = new Notifier({
projectId: 1, // Airbrake project id
projectKey: 'FIXME', // Airbrake project API key
});
airbrake.addFilter(function (notice) {
notice.context.hooks = 'client';
return notice;
});
/** @type {import('@sveltejs/kit').HandleClientError} */
export function handleError({ error, event }) {
const errorId = crypto.randomUUID();
// example integration with https://airbrake.io/
airbrake.notify({
error: error,
params: { errorId: errorId, event: event },
});
return {
message: error,
errorId,
};
}
```
## Test
To test that server hook has been installed correctly in your Svelte project.
```js
// +page.server.js
import { error } from '@sveltejs/kit';
import * as db from '$lib/server/database';
/** @type {import('./$types').PageServerLoad} */
export async function load({ params }) {
const post = await db.getPost(params.slug);
if (!post) {
throw error(404, {
message: 'Not found',
});
}
return { post };
}
```
To test that client hook has been installed correctly in your Svelte project, just open up the JavaScript console in your internet browser and paste in:
```js
window.onerror('TestError: This is a test', 'path/to/file.js', 123);
```
================================================
FILE: packages/browser/examples/vuejs/README.md
================================================
Usage with Vue.js
==================
### Vue 2
You can start reporting errors from your Vue 2 app by configuring an
[`errorHandler`](https://vuejs.org/v2/api/#errorHandler) that uses a `Notifier`
initialized with your `projectId` and `projectKey`.
```js
import { Notifier } from '@airbrake/browser'
var airbrake = new Notifier({
projectId: 1,
projectKey: 'FIXME'
})
Vue.config.errorHandler = function(err, _vm, info) {
airbrake.notify({
error: err,
params: {info: info}
})
}
```
### Vue 3
You can start reporting errors from your Vue 3 app by configuring an
[`errorHandler`](https://v3.vuejs.org/api/application-config.html#errorhandler)
that uses a `Notifier` initialized with your `projectId` and `projectKey`.
```js
import { createApp } from "vue"
import App from "./App.vue"
import { Notifier } from '@airbrake/browser'
var airbrake = new Notifier({
projectId: 1,
projectKey: 'FIXME'
})
let app = createApp(App)
app.config.errorHandler = function(err, _vm, info) {
airbrake.notify({
error: err,
params: {info: info}
})
}
app.mount("#app")
```
================================================
FILE: packages/browser/jest.config.js
================================================
module.exports = {
transform: {
'^.+\\.jsx?$': 'babel-jest',
'^.+\\.tsx?$': 'ts-jest',
},
testEnvironment: 'jsdom',
roots: ['tests'],
clearMocks: true,
};
================================================
FILE: packages/browser/package.json
================================================
{
"name": "@airbrake/browser",
"version": "2.1.9",
"description": "Official Airbrake notifier for browsers",
"author": "Airbrake",
"license": "MIT",
"repository": {
"type": "git",
"url": "git://github.com/airbrake/airbrake-js.git",
"directory": "packages/browser"
},
"homepage": "https://github.com/airbrake/airbrake-js/tree/master/packages/browser",
"keywords": [
"exception",
"error",
"airbrake",
"notifier"
],
"dependencies": {
"@types/promise-polyfill": "^6.0.3",
"@types/request": "2.48.8",
"cross-fetch": "^3.1.5",
"error-stack-parser": "^2.0.4",
"promise-polyfill": "^8.1.3",
"tdigest": "^0.1.1"
},
"devDependencies": {
"@babel/core": "^7.9.0",
"@babel/preset-env": "^7.9.0",
"@rollup/plugin-commonjs": "^24.0.0",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^11.0.0",
"babel-jest": "^29.3.1",
"jest": "^27.3.1",
"prettier": "^2.0.2",
"rollup": "^2.6.1",
"rollup-plugin-terser": "^7.0.0",
"ts-jest": "^27.1.0",
"tslib": "^2.0.0",
"tslint": "^6.1.0",
"tslint-config-prettier": "^1.18.0",
"tslint-plugin-prettier": "^2.3.0",
"typescript": "^4.0.2"
},
"main": "dist/index.js",
"module": "esm/index.js",
"unpkg": "umd/airbrake.js",
"jsdelivr": "umd/airbrake.js",
"files": [
"dist/",
"esm/",
"umd/",
"README.md",
"LICENSE"
],
"scripts": {
"build": "yarn build:cjs && yarn build:esm && yarn build:umd",
"build:watch": "concurrently 'yarn build:cjs:watch' 'yarn build:esm:watch'",
"build:cjs": "tsc -p tsconfig.cjs.json",
"build:cjs:watch": "tsc -p tsconfig.cjs.json -w --preserveWatchOutput",
"build:esm": "tsc -p tsconfig.esm.json",
"build:esm:watch": "tsc -p tsconfig.esm.json -w --preserveWatchOutput",
"build:umd": "rollup --config",
"clean": "rm -rf dist esm umd",
"lint": "tslint -p .",
"test": "jest"
}
}
================================================
FILE: packages/browser/rollup.config.js
================================================
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import typescript from '@rollup/plugin-typescript';
import { terser } from 'rollup-plugin-terser';
const pkg = require('./package.json');
const webPlugins = [
resolve({ browser: true }),
commonjs(),
typescript({ tsconfig: './tsconfig.umd.json' }),
];
function umd(cfg) {
return Object.assign(
{
format: 'umd',
banner: `/* airbrake-js v${pkg.version} */`,
sourcemap: true,
name: 'Airbrake',
},
cfg,
);
}
export default {
input: 'src/index.ts',
output: [
umd({ file: 'umd/airbrake.js' }),
umd({ file: 'umd/airbrake.min.js', plugins: [terser()] }),
],
plugins: webPlugins,
};
================================================
FILE: packages/browser/src/base_notifier.ts
================================================
import Promise from 'promise-polyfill';
import { IFuncWrapper } from './func_wrapper';
import { jsonifyNotice } from './jsonify_notice';
import { INotice } from './notice';
import { Scope } from './scope';
import { espProcessor } from './processor/esp';
import { Processor } from './processor/processor';
import { angularMessageFilter } from './filter/angular_message';
import { makeDebounceFilter } from './filter/debounce';
import { Filter } from './filter/filter';
import { ignoreNoiseFilter } from './filter/ignore_noise';
import { uncaughtMessageFilter } from './filter/uncaught_message';
import { makeRequester, Requester } from './http_req';
import { IOptions } from './options';
import { QueriesStats } from './queries';
import { QueueMetric, QueuesStats } from './queues';
import { RouteMetric, RoutesBreakdowns, RoutesStats } from './routes';
import { NOTIFIER_NAME, NOTIFIER_VERSION, NOTIFIER_URL } from './version';
import { PerformanceFilter } from './filter/performance_filter';
import { RemoteSettings } from './remote_settings';
export class BaseNotifier {
routes: Routes;
queues: Queues;
queries: QueriesStats;
_opt: IOptions;
_url: string;
_processor: Processor;
_requester: Requester;
_filters: Filter[] = [];
_performanceFilters: PerformanceFilter[] = [];
_scope = new Scope();
_onClose: (() => void)[] = [];
constructor(opt: IOptions) {
if (!opt.projectId || !opt.projectKey) {
throw new Error('airbrake: projectId and projectKey are required');
}
this._opt = opt;
this._opt.host = this._opt.host || 'https://api.airbrake.io';
this._opt.remoteConfigHost =
this._opt.remoteConfigHost || 'https://notifier-configs.airbrake.io';
this._opt.apmHost = this._opt.apmHost || 'https://api.airbrake.io';
this._opt.timeout = this._opt.timeout || 10000;
this._opt.keysBlocklist = this._opt.keysBlocklist || [/password/, /secret/];
this._url = `${this._opt.host}/api/v3/projects/${this._opt.projectId}/notices?key=${this._opt.projectKey}`;
this._opt.errorNotifications = this._opt.errorNotifications !== false;
this._opt.performanceStats = this._opt.performanceStats !== false;
this._opt.queryStats = this._opt.queryStats !== false;
this._opt.queueStats = this._opt.queueStats !== false;
this._opt.remoteConfig = this._opt.remoteConfig !== false;
this._processor = this._opt.processor || espProcessor;
this._requester = makeRequester(this._opt);
this.addFilter(ignoreNoiseFilter);
this.addFilter(makeDebounceFilter());
this.addFilter(uncaughtMessageFilter);
this.addFilter(angularMessageFilter);
this.addFilter((notice: INotice): INotice | null => {
notice.context.notifier = {
name: NOTIFIER_NAME,
version: NOTIFIER_VERSION,
url: NOTIFIER_URL,
};
if (this._opt.environment) {
notice.context.environment = this._opt.environment;
}
return notice;
});
this.routes = new Routes(this);
this.queues = new Queues(this);
this.queries = new QueriesStats(this._opt);
if (this._opt.remoteConfig) {
const pollerId = new RemoteSettings(this._opt).poll();
this._onClose.push(() => clearInterval(pollerId));
}
}
close(): void {
for (let fn of this._onClose) {
fn();
}
}
scope(): Scope {
return this._scope;
}
setActiveScope(scope: Scope) {
this._scope = scope;
}
addFilter(filter: Filter): void {
this._filters.push(filter);
}
addPerformanceFilter(performanceFilter: PerformanceFilter) {
this._performanceFilters.push(performanceFilter);
}
notify(err: any): Promise {
if (typeof err !== 'object' || err === null || !('error' in err)) {
err = { error: err };
}
this.handleFalseyError(err);
let notice = this.newNotice(err);
if (!this._opt.errorNotifications) {
notice.error = new Error(
`airbrake: not sending this error, errorNotifications is disabled err=${JSON.stringify(
err.error
)}`
);
return Promise.resolve(notice);
}
let error = this._processor(err.error);
notice.errors.push(error);
for (let filter of this._filters) {
let r = filter(notice);
if (r === null) {
notice.error = new Error('airbrake: error is filtered');
return Promise.resolve(notice);
}
notice = r;
}
if (!notice.context) {
notice.context = {};
}
notice.context.language = 'JavaScript';
return this._sendNotice(notice);
}
private handleFalseyError(err: any) {
if (Number.isNaN(err.error)) {
err.error = new Error('NaN');
} else if (err.error === undefined) {
err.error = new Error('undefined');
} else if (err.error === '') {
err.error = new Error('');
} else if (err.error === null) {
err.error = new Error('null');
}
}
private newNotice(err: any): INotice {
return {
errors: [],
context: {
severity: 'error',
...this.scope().context(),
...err.context,
},
params: err.params || {},
environment: err.environment || {},
session: err.session || {},
};
}
_sendNotice(notice: INotice): Promise {
let body = jsonifyNotice(notice, {
keysBlocklist: this._opt.keysBlocklist,
});
if (this._opt.reporter) {
if (typeof this._opt.reporter === 'function') {
return this._opt.reporter(notice);
} else {
console.warn('airbrake: options.reporter must be a function');
}
}
let req = {
method: 'POST',
url: this._url,
body,
};
return this._requester(req)
.then((resp) => {
notice.id = resp.json.id;
notice.url = resp.json.url;
return notice;
})
.catch((err) => {
notice.error = err;
return notice;
});
}
wrap(fn, props: string[] = []): IFuncWrapper {
if (fn._airbrake) {
return fn;
}
// tslint:disable-next-line:no-this-assignment
let client = this;
let airbrakeWrapper = function () {
let fnArgs = Array.prototype.slice.call(arguments);
let wrappedArgs = client._wrapArguments(fnArgs);
try {
return fn.apply(this, wrappedArgs);
} catch (err) {
client.notify({ error: err, params: { arguments: fnArgs } });
client._ignoreNextWindowError();
throw err;
}
} as IFuncWrapper;
for (let prop in fn) {
if (fn.hasOwnProperty(prop)) {
airbrakeWrapper[prop] = fn[prop];
}
}
for (let prop of props) {
if (fn.hasOwnProperty(prop)) {
airbrakeWrapper[prop] = fn[prop];
}
}
airbrakeWrapper._airbrake = true;
airbrakeWrapper.inner = fn;
return airbrakeWrapper;
}
_wrapArguments(args: any[]): any[] {
for (let i = 0; i < args.length; i++) {
let arg = args[i];
if (typeof arg === 'function') {
args[i] = this.wrap(arg);
}
}
return args;
}
_ignoreNextWindowError() {}
call(fn, ..._args: any[]): any {
let wrapper = this.wrap(fn);
return wrapper.apply(this, Array.prototype.slice.call(arguments, 1));
}
}
class Routes {
_notifier: BaseNotifier;
_routes: RoutesStats;
_breakdowns: RoutesBreakdowns;
_opt: IOptions;
constructor(notifier: BaseNotifier) {
this._notifier = notifier;
this._routes = new RoutesStats(notifier._opt);
this._breakdowns = new RoutesBreakdowns(notifier._opt);
this._opt = notifier._opt;
}
start(
method = '',
route = '',
statusCode = 0,
contentType = ''
): RouteMetric {
const metric = new RouteMetric(method, route, statusCode, contentType);
if (!this._opt.performanceStats) {
return metric;
}
const scope = this._notifier.scope().clone();
scope.setContext({ httpMethod: method, route });
scope.setRouteMetric(metric);
this._notifier.setActiveScope(scope);
return metric;
}
notify(req: RouteMetric): void {
if (!this._opt.performanceStats) {
return;
}
req.end();
for (const performanceFilter of this._notifier._performanceFilters) {
if (performanceFilter(req) === null) {
return;
}
}
this._routes.notify(req);
this._breakdowns.notify(req);
}
}
class Queues {
_notifier: BaseNotifier;
_queues: QueuesStats;
_opt: IOptions;
constructor(notifier: BaseNotifier) {
this._notifier = notifier;
this._queues = new QueuesStats(notifier._opt);
this._opt = notifier._opt;
}
start(queue: string): QueueMetric {
const metric = new QueueMetric(queue);
if (!this._opt.performanceStats) {
return metric;
}
const scope = this._notifier.scope().clone();
scope.setContext({ queue });
scope.setQueueMetric(metric);
this._notifier.setActiveScope(scope);
return metric;
}
notify(q: QueueMetric): void {
if (!this._opt.performanceStats) {
return;
}
q.end();
this._queues.notify(q);
}
}
================================================
FILE: packages/browser/src/filter/angular_message.ts
================================================
import { INotice } from '../notice';
let re = new RegExp(
[
'^',
'\\[(\\$.+)\\]', // type
'\\s',
'([\\s\\S]+)', // message
'$',
].join('')
);
export function angularMessageFilter(notice: INotice): INotice {
let err = notice.errors[0];
if (err.type !== '' && err.type !== 'Error') {
return notice;
}
let m = err.message.match(re);
if (m !== null) {
err.type = m[1];
err.message = m[2];
}
return notice;
}
================================================
FILE: packages/browser/src/filter/debounce.ts
================================================
import { INotice } from '../notice';
import { Filter } from './filter';
export function makeDebounceFilter(): Filter {
let lastNoticeJSON: string;
let timeout;
return (notice: INotice): INotice | null => {
let s = JSON.stringify(notice.errors);
if (s === lastNoticeJSON) {
return null;
}
if (timeout) {
clearTimeout(timeout);
}
lastNoticeJSON = s;
timeout = setTimeout(() => {
lastNoticeJSON = '';
}, 1000);
return notice;
};
}
================================================
FILE: packages/browser/src/filter/filter.ts
================================================
import { INotice } from '../notice';
export type Filter = (notice: INotice) => INotice | null;
================================================
FILE: packages/browser/src/filter/ignore_noise.ts
================================================
import { INotice } from '../notice';
const IGNORED_MESSAGES = [
'Script error',
'Script error.',
'InvalidAccessError',
];
export function ignoreNoiseFilter(notice: INotice): INotice | null {
let err = notice.errors[0];
if (err.type === '' && IGNORED_MESSAGES.indexOf(err.message) !== -1) {
return null;
}
if (err.backtrace && err.backtrace.length > 0) {
let frame = err.backtrace[0];
if (frame.file === '') {
return null;
}
}
return notice;
}
================================================
FILE: packages/browser/src/filter/performance_filter.ts
================================================
import { RouteMetric } from '../routes';
export type PerformanceFilter = (metric: RouteMetric) => RouteMetric | null;
================================================
FILE: packages/browser/src/filter/uncaught_message.ts
================================================
import { INotice } from '../notice';
let re = new RegExp(
[
'^',
'Uncaught\\s',
'(.+?)', // type
':\\s',
'(.+)', // message
'$',
].join('')
);
export function uncaughtMessageFilter(notice: INotice): INotice {
let err = notice.errors[0];
if (err.type !== '' && err.type !== 'Error') {
return notice;
}
let m = err.message.match(re);
if (m !== null) {
err.type = m[1];
err.message = m[2];
}
return notice;
}
================================================
FILE: packages/browser/src/filter/window.ts
================================================
import { INotice } from '../notice';
export function windowFilter(notice: INotice): INotice {
if (window.navigator && window.navigator.userAgent) {
notice.context.userAgent = window.navigator.userAgent;
}
if (window.location) {
notice.context.url = String(window.location);
// Set root directory to group errors on different subdomains together.
notice.context.rootDirectory =
window.location.protocol + '//' + window.location.host;
}
return notice;
}
================================================
FILE: packages/browser/src/func_wrapper.ts
================================================
export interface IFuncWrapper {
(): any;
inner: () => any;
_airbrake?: boolean;
}
================================================
FILE: packages/browser/src/http_req/api.ts
================================================
export interface IHttpRequest {
method: string;
url: string;
body?: string;
timeout?: number;
headers?: any;
}
export interface IHttpResponse {
json: any;
}
export type Requester = (req: IHttpRequest) => Promise;
export let errors = {
unauthorized: new Error(
'airbrake: unauthorized: project id or key are wrong'
),
ipRateLimited: new Error('airbrake: IP is rate limited'),
};
================================================
FILE: packages/browser/src/http_req/fetch.ts
================================================
import fetch from 'cross-fetch';
import Promise from 'promise-polyfill';
import { errors, IHttpRequest, IHttpResponse } from './api';
let rateLimitReset = 0;
export function request(req: IHttpRequest): Promise {
let utime = Date.now() / 1000;
if (utime < rateLimitReset) {
return Promise.reject(errors.ipRateLimited);
}
let opt = {
method: req.method,
body: req.body,
headers: req.headers,
};
return fetch(req.url, opt).then((resp: Response) => {
if (resp.status === 401) {
throw errors.unauthorized;
}
if (resp.status === 429) {
let s = resp.headers.get('X-RateLimit-Delay');
if (!s) {
throw errors.ipRateLimited;
}
let n = parseInt(s, 10);
if (n > 0) {
rateLimitReset = Date.now() / 1000 + n;
}
throw errors.ipRateLimited;
}
if (resp.status === 204) {
return { json: null };
}
if (resp.status === 404) {
throw new Error('404 Not Found');
}
if (resp.status >= 200 && resp.status < 300) {
return resp.json().then((json) => {
return { json };
});
}
if (resp.status >= 400 && resp.status < 500) {
return resp.json().then((json) => {
let err = new Error(json.message);
throw err;
});
}
return resp.text().then((body) => {
let err = new Error(
`airbrake: fetch: unexpected response: code=${resp.status} body='${body}'`
);
throw err;
});
});
}
================================================
FILE: packages/browser/src/http_req/index.ts
================================================
import { IOptions } from '../options';
import { Requester } from './api';
import { request as fetchRequest } from './fetch';
import { makeRequester as makeNodeRequester } from './node';
export { Requester };
export function makeRequester(opts: IOptions): Requester {
if (opts.request) {
return makeNodeRequester(opts.request);
}
return fetchRequest;
}
================================================
FILE: packages/browser/src/http_req/node.ts
================================================
import * as request_lib from 'request';
import Promise from 'promise-polyfill';
import { errors, IHttpRequest, IHttpResponse, Requester } from './api';
type requestAPI = request_lib.RequestAPI<
request_lib.Request,
request_lib.CoreOptions,
request_lib.RequiredUriUrl
>;
export function makeRequester(api: requestAPI): Requester {
return (req: IHttpRequest): Promise => {
return request(req, api);
};
}
let rateLimitReset = 0;
function request(req: IHttpRequest, api: requestAPI): Promise {
let utime = Date.now() / 1000;
if (utime < rateLimitReset) {
return Promise.reject(errors.ipRateLimited);
}
return new Promise((resolve, reject) => {
api(
{
url: req.url,
method: req.method,
body: req.body,
headers: {
'content-type': 'application/json',
},
timeout: req.timeout,
},
(error: any, resp: request_lib.RequestResponse, body: any): void => {
if (error) {
reject(error);
return;
}
if (!resp.statusCode) {
error = new Error(
`airbrake: request: response statusCode is ${resp.statusCode}`
);
reject(error);
return;
}
if (resp.statusCode === 401) {
reject(errors.unauthorized);
return;
}
if (resp.statusCode === 429) {
reject(errors.ipRateLimited);
let h = resp.headers['x-ratelimit-delay'];
if (!h) {
return;
}
let s: string;
if (typeof h === 'string') {
s = h;
} else if (h instanceof Array) {
s = h[0];
} else {
return;
}
let n = parseInt(s, 10);
if (n > 0) {
rateLimitReset = Date.now() / 1000 + n;
}
return;
}
if (resp.statusCode === 204) {
resolve({ json: null });
return;
}
if (resp.statusCode >= 200 && resp.statusCode < 300) {
let json;
try {
json = JSON.parse(body);
} catch (err) {
reject(err);
return;
}
resolve(json);
return;
}
if (resp.statusCode >= 400 && resp.statusCode < 500) {
let json;
try {
json = JSON.parse(body);
} catch (err) {
reject(err);
return;
}
error = new Error(json.message);
reject(error);
return;
}
body = body.trim();
error = new Error(
`airbrake: node: unexpected response: code=${resp.statusCode} body='${body}'`
);
reject(error);
}
);
});
}
================================================
FILE: packages/browser/src/index.ts
================================================
export { Notifier } from './notifier';
export { BaseNotifier } from './base_notifier';
export { INotice } from './notice';
export { IOptions } from './options';
export { QueryInfo } from './queries';
export { Scope } from './scope';
================================================
FILE: packages/browser/src/instrumentation/console.ts
================================================
import { IFuncWrapper } from '../func_wrapper';
import { Notifier } from '../notifier';
const CONSOLE_METHODS = ['debug', 'log', 'info', 'warn', 'error'];
export function instrumentConsole(notifier: Notifier): void {
// tslint:disable-next-line:no-this-assignment
for (let m of CONSOLE_METHODS) {
if (!(m in console)) {
continue;
}
const oldFn = console[m];
let newFn = ((...args) => {
oldFn.apply(console, args);
notifier.scope().pushHistory({
type: 'log',
severity: m,
arguments: args,
});
}) as IFuncWrapper;
newFn.inner = oldFn;
console[m] = newFn;
}
}
================================================
FILE: packages/browser/src/instrumentation/dom.ts
================================================
import { Notifier } from '../notifier';
const elemAttrs = ['type', 'name', 'src'];
export function instrumentDOM(notifier: Notifier) {
const handler = makeEventHandler(notifier);
if (window.addEventListener) {
window.addEventListener('load', handler);
window.addEventListener(
'error',
(event: Event): void => {
if (getProp(event, 'error')) {
return;
}
handler(event);
},
true
);
}
if (typeof document === 'object' && document.addEventListener) {
document.addEventListener('DOMContentLoaded', handler);
document.addEventListener('click', handler);
document.addEventListener('keypress', handler);
}
}
function makeEventHandler(notifier: Notifier): EventListener {
return (event: Event): void => {
let target = getProp(event, 'target') as HTMLElement | null;
if (!target) {
return;
}
let state: any = { type: event.type };
try {
state.target = elemPath(target);
} catch (err) {
state.target = `<${String(err)}>`;
}
notifier.scope().pushHistory(state);
};
}
function elemName(elem: HTMLElement): string {
if (!elem) {
return '';
}
let s: string[] = [];
if (elem.tagName) {
s.push(elem.tagName.toLowerCase());
}
if (elem.id) {
s.push('#');
s.push(elem.id);
}
if (elem.classList && Array.from) {
s.push('.');
s.push(Array.from(elem.classList).join('.'));
} else if (elem.className) {
let str = classNameString(elem.className);
if (str !== '') {
s.push('.');
s.push(str);
}
}
if (elem.getAttribute) {
for (let attr of elemAttrs) {
let value = elem.getAttribute(attr);
if (value) {
s.push(`[${attr}="${value}"]`);
}
}
}
return s.join('');
}
function classNameString(name: any): string {
if (name.split) {
return name.split(' ').join('.');
}
if (name.baseVal && name.baseVal.split) {
// SVGAnimatedString
return name.baseVal.split(' ').join('.');
}
console.error('unsupported HTMLElement.className type', typeof name);
return '';
}
function elemPath(elem: HTMLElement): string {
const maxLen = 10;
let path: string[] = [];
let parent = elem;
while (parent) {
let name = elemName(parent);
if (name !== '') {
path.push(name);
if (path.length > maxLen) {
break;
}
}
parent = parent.parentNode as HTMLElement;
}
if (path.length === 0) {
return String(elem);
}
return path.reverse().join(' > ');
}
function getProp(obj: any, prop: string): any {
try {
return obj[prop];
} catch (_) {
// Permission denied to access property
return null;
}
}
================================================
FILE: packages/browser/src/instrumentation/fetch.ts
================================================
import { Notifier } from '../notifier';
export function instrumentFetch(notifier: Notifier): void {
// tslint:disable-next-line:no-this-assignment
let oldFetch = window.fetch;
window.fetch = function (
req: RequestInfo,
options?: RequestInit
): Promise {
let state: any = {
type: 'xhr',
date: new Date(),
};
state.method = options && options.method ? options.method : 'GET';
if (typeof req === 'string') {
state.url = req;
} else {
state.method = req.method;
state.url = req.url;
}
// Some platforms (e.g. react-native) implement fetch via XHR.
notifier._ignoreNextXHR++;
setTimeout(() => notifier._ignoreNextXHR--);
return oldFetch
.apply(this, arguments)
.then((resp: Response) => {
state.statusCode = resp.status;
state.duration = new Date().getTime() - state.date.getTime();
notifier.scope().pushHistory(state);
return resp;
})
.catch((err) => {
state.error = err;
state.duration = new Date().getTime() - state.date.getTime();
notifier.scope().pushHistory(state);
throw err;
});
};
}
================================================
FILE: packages/browser/src/instrumentation/location.ts
================================================
import { Notifier } from '../notifier';
let lastLocation = '';
// In some environments (i.e. Cypress) document.location may sometimes be null
function getCurrentLocation(): string | null {
return document.location && document.location.pathname;
}
export function instrumentLocation(notifier: Notifier): void {
lastLocation = getCurrentLocation();
const oldFn = window.onpopstate;
window.onpopstate = function abOnpopstate(_event: PopStateEvent): any {
const url = getCurrentLocation();
if (url) {
recordLocation(notifier, url);
}
if (oldFn) {
return oldFn.apply(this, arguments);
}
};
const oldPushState = history.pushState;
history.pushState = function abPushState(
_state: any,
_title: string,
url?: string | null
): void {
if (url) {
recordLocation(notifier, url.toString());
}
oldPushState.apply(this, arguments);
};
}
function recordLocation(notifier: Notifier, url: string): void {
let index = url.indexOf('://');
if (index >= 0) {
url = url.slice(index + 3);
index = url.indexOf('/');
url = index >= 0 ? url.slice(index) : '/';
} else if (url.charAt(0) !== '/') {
url = '/' + url;
}
notifier.scope().pushHistory({
type: 'location',
from: lastLocation,
to: url,
});
lastLocation = url;
}
================================================
FILE: packages/browser/src/instrumentation/unhandledrejection.ts
================================================
import { Notifier } from '../notifier';
export function instrumentUnhandledrejection(notifier: Notifier): void {
const handler = onUnhandledrejection.bind(notifier);
window.addEventListener('unhandledrejection', handler);
notifier._onClose.push(() => {
window.removeEventListener('unhandledrejection', handler);
});
}
function onUnhandledrejection(e: any): void {
// Handle native or bluebird Promise rejections
// https://developer.mozilla.org/en-US/docs/Web/Events/unhandledrejection
// http://bluebirdjs.com/docs/api/error-management-configuration.html
let reason = e.reason || (e.detail && e.detail.reason);
if (!reason) return;
let msg = reason.message || String(reason);
if (msg.indexOf && msg.indexOf('airbrake: ') === 0) return;
if (typeof reason !== 'object' || reason.error === undefined) {
this.notify({
error: reason,
context: {
unhandledRejection: true,
},
});
return;
}
this.notify({ ...reason, context: { unhandledRejection: true } });
}
================================================
FILE: packages/browser/src/instrumentation/xhr.ts
================================================
import { Notifier } from '../notifier';
interface IXMLHttpRequestWithState extends XMLHttpRequest {
__state: any;
}
export function instrumentXHR(notifier: Notifier): void {
function recordReq(req: IXMLHttpRequestWithState): void {
const state = req.__state;
state.statusCode = req.status;
state.duration = new Date().getTime() - state.date.getTime();
notifier.scope().pushHistory(state);
}
const oldOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function abOpen(
method: string,
url: string,
_async?: boolean,
_user?: string,
_password?: string
): void {
if (notifier._ignoreNextXHR === 0) {
this.__state = {
type: 'xhr',
method,
url,
};
}
oldOpen.apply(this, arguments);
};
const oldSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function abSend(_data?: any): void {
let oldFn = this.onreadystatechange;
this.onreadystatechange = function (_ev: Event): any {
if (this.readyState === 4 && this.__state) {
recordReq(this);
}
if (oldFn) {
return oldFn.apply(this, arguments);
}
};
if (this.__state) {
(this as IXMLHttpRequestWithState).__state.date = new Date();
}
return oldSend.apply(this, arguments);
};
}
================================================
FILE: packages/browser/src/jsonify_notice.ts
================================================
import { INotice } from './notice';
const FILTERED = '[Filtered]';
const MAX_OBJ_LENGTH = 128;
// jsonifyNotice serializes notice to JSON and truncates params,
// environment and session keys.
export function jsonifyNotice(
notice: INotice,
{ maxLength = 64000, keysBlocklist = [], keysAllowlist = [] } = {}
): string {
if (notice.errors) {
for (let i = 0; i < notice.errors.length; i++) {
let t = new Truncator({ keysBlocklist, keysAllowlist });
notice.errors[i] = t.truncate(notice.errors[i]);
}
}
let s = '';
let keys = ['params', 'environment', 'session'];
for (let level = 0; level < 8; level++) {
let opts = { level, keysBlocklist, keysAllowlist };
for (let key of keys) {
let obj = notice[key];
if (obj) {
notice[key] = truncate(obj, opts);
}
}
s = JSON.stringify(notice);
if (s.length < maxLength) {
return s;
}
}
let params = {
json: s.slice(0, Math.floor(maxLength / 2)) + '...',
};
keys.push('errors');
for (let key of keys) {
let obj = notice[key];
if (!obj) {
continue;
}
s = JSON.stringify(obj);
params[key] = s.length;
}
let err = new Error(
`airbrake: notice exceeds max length and can't be truncated`
);
(err as any).params = params;
throw err;
}
function scale(num: number, level: number): number {
return num >> level || 1;
}
interface ITruncatorOptions {
level?: number;
keysBlocklist?: any[];
keysAllowlist?: any[];
}
class Truncator {
private maxStringLength = 1024;
private maxObjectLength = MAX_OBJ_LENGTH;
private maxArrayLength = MAX_OBJ_LENGTH;
private maxDepth = 8;
private keys: string[] = [];
private keysBlocklist: any[] = [];
private keysAllowlist: any[] = [];
private seen: any[] = [];
constructor(opts: ITruncatorOptions) {
let level = opts.level || 0;
this.keysBlocklist = opts.keysBlocklist || [];
this.keysAllowlist = opts.keysAllowlist || [];
this.maxStringLength = scale(this.maxStringLength, level);
this.maxObjectLength = scale(this.maxObjectLength, level);
this.maxArrayLength = scale(this.maxArrayLength, level);
this.maxDepth = scale(this.maxDepth, level);
}
public truncate(value: any, key = '', depth = 0): any {
if (value === null || value === undefined) {
return value;
}
switch (typeof value) {
case 'boolean':
case 'number':
case 'function':
return value;
case 'string':
return this.truncateString(value);
case 'object':
break;
default:
return this.truncateString(String(value));
}
if (value instanceof String) {
return this.truncateString(value.toString());
}
if (
value instanceof Boolean ||
value instanceof Number ||
value instanceof Date ||
value instanceof RegExp
) {
return value;
}
if (value instanceof Error) {
return this.truncateString(value.toString());
}
if (this.seen.indexOf(value) >= 0) {
return `[Circular ${this.getPath(value)}]`;
}
let type = objectType(value);
depth++;
if (depth > this.maxDepth) {
return `[Truncated ${type}]`;
}
this.keys.push(key);
this.seen.push(value);
switch (type) {
case 'Array':
return this.truncateArray(value, depth);
case 'Object':
return this.truncateObject(value, depth);
default:
let saved = this.maxDepth;
this.maxDepth = 0;
let obj = this.truncateObject(value, depth);
obj.__type = type;
this.maxDepth = saved;
return obj;
}
}
private getPath(value): string {
let index = this.seen.indexOf(value);
let path = [this.keys[index]];
for (let i = index; i >= 0; i--) {
let sub = this.seen[i];
if (sub && getAttr(sub, path[0]) === value) {
value = sub;
path.unshift(this.keys[i]);
}
}
return '~' + path.join('.');
}
private truncateString(s: string): string {
if (s.length > this.maxStringLength) {
return s.slice(0, this.maxStringLength) + '...';
}
return s;
}
private truncateArray(arr: any[], depth = 0): any[] {
let length = 0;
let dst: any = [];
for (let i = 0; i < arr.length; i++) {
let el = arr[i];
dst.push(this.truncate(el, i.toString(), depth));
length++;
if (length >= this.maxArrayLength) {
break;
}
}
return dst;
}
private truncateObject(obj: any, depth = 0): any {
let length = 0;
let dst = {};
for (let key in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, key)) {
continue;
}
if (this.filterKey(key, dst)) continue;
let value = getAttr(obj, key);
if (value === undefined || typeof value === 'function') {
continue;
}
dst[key] = this.truncate(value, key, depth);
length++;
if (length >= this.maxObjectLength) {
break;
}
}
return dst;
}
private filterKey(key: string, obj: any): boolean {
if (
(this.keysAllowlist.length > 0 && !isInList(key, this.keysAllowlist)) ||
(this.keysAllowlist.length === 0 && isInList(key, this.keysBlocklist))
) {
obj[key] = FILTERED;
return true;
}
return false;
}
}
export function truncate(value: any, opts: ITruncatorOptions = {}): any {
let t = new Truncator(opts);
return t.truncate(value);
}
function getAttr(obj: any, attr: string): any {
// Ignore browser specific exception trying to read an attribute (#79).
try {
return obj[attr];
} catch (_) {
return;
}
}
function objectType(obj: any): string {
let s = Object.prototype.toString.apply(obj);
return s.slice('[object '.length, -1);
}
function isInList(key: string, list: any[]): boolean {
for (let v of list) {
if (v === key) {
return true;
}
if (v instanceof RegExp) {
if (key.match(v)) {
return true;
}
}
}
return false;
}
================================================
FILE: packages/browser/src/metrics.ts
================================================
export interface IMetric {
isRecording(): boolean;
startSpan(name: string, startTime?: Date): void;
endSpan(name: string, endTime?: Date): void;
_incGroup(name: string, ms: number): void;
}
export class Span {
_metric: IMetric;
name: string;
startTime: Date;
endTime: Date;
_dur = 0;
_level = 0;
constructor(metric: IMetric, name: string, startTime?: Date) {
this._metric = metric;
this.name = name;
this.startTime = startTime || new Date();
}
end(endTime?: Date) {
this.endTime = endTime ? endTime : new Date();
this._dur += this.endTime.getTime() - this.startTime.getTime();
this._metric._incGroup(this.name, this._dur);
this._metric = null;
}
_pause() {
if (this._paused()) {
return;
}
let now = new Date();
this._dur += now.getTime() - this.startTime.getTime();
this.startTime = null;
}
_resume() {
if (!this._paused()) {
return;
}
this.startTime = new Date();
}
_paused() {
return this.startTime == null;
}
}
export class BaseMetric implements IMetric {
startTime: Date;
endTime: Date;
_spans = {};
_groups = {};
constructor() {
this.startTime = new Date();
}
end(endTime?: Date): void {
if (!this.endTime) {
this.endTime = endTime || new Date();
}
}
isRecording(): boolean {
return true;
}
startSpan(name: string, startTime?: Date): void {
let span = this._spans[name];
if (span) {
span._level++;
} else {
span = new Span(this, name, startTime);
this._spans[name] = span;
}
}
endSpan(name: string, endTime?: Date): void {
let span = this._spans[name];
if (!span) {
console.error('airbrake: span=%s does not exist', name);
return;
}
if (span._level > 0) {
span._level--;
} else {
span.end(endTime);
delete this._spans[span.name];
}
}
_incGroup(name: string, ms: number): void {
this._groups[name] = (this._groups[name] || 0) + ms;
}
_duration(): number {
if (!this.endTime) {
this.endTime = new Date();
}
return this.endTime.getTime() - this.startTime.getTime();
}
}
export class NoopMetric implements IMetric {
isRecording(): boolean {
return false;
}
startSpan(_name: string, _startTime?: Date): void {}
endSpan(_name: string, _startTime?: Date): void {}
_incGroup(_name: string, _ms: number): void {}
}
================================================
FILE: packages/browser/src/notice.ts
================================================
export interface INoticeFrame {
function: string;
file: string;
line: number;
column: number;
}
export interface INoticeError {
type: string;
message: string;
backtrace: INoticeFrame[];
}
export interface INotice {
id?: string;
url?: string;
error?: Error;
errors?: INoticeError[];
context?: any;
params?: any;
session?: any;
environment?: any;
}
================================================
FILE: packages/browser/src/notifier.ts
================================================
import Promise from 'promise-polyfill';
import { BaseNotifier } from './base_notifier';
import { windowFilter } from './filter/window';
import { instrumentConsole } from './instrumentation/console';
import { instrumentDOM } from './instrumentation/dom';
import { instrumentFetch } from './instrumentation/fetch';
import { instrumentLocation } from './instrumentation/location';
import { instrumentXHR } from './instrumentation/xhr';
import { instrumentUnhandledrejection } from './instrumentation/unhandledrejection';
import { INotice } from './notice';
import { IInstrumentationOptions, IOptions } from './options';
interface ITodo {
err: any;
resolve: (notice: INotice) => void;
reject: (err: Error) => void;
}
export class Notifier extends BaseNotifier {
protected offline = false;
protected todo: ITodo[] = [];
_ignoreWindowError = 0;
_ignoreNextXHR = 0;
constructor(opt: IOptions) {
super(opt);
if (typeof window === 'undefined') {
return;
}
this.addFilter(windowFilter);
if (window.addEventListener) {
this.onOnline = this.onOnline.bind(this);
window.addEventListener('online', this.onOnline);
this.onOffline = this.onOffline.bind(this);
window.addEventListener('offline', this.onOffline);
this._onClose.push(() => {
window.removeEventListener('online', this.onOnline);
window.removeEventListener('offline', this.onOffline);
});
}
this._instrument(opt.instrumentation);
}
_instrument(opt: IInstrumentationOptions = {}) {
if (opt.console === undefined) {
opt.console = !isDevEnv(this._opt.environment);
}
if (enabled(opt.onerror)) {
// tslint:disable-next-line:no-this-assignment
let self = this;
let oldHandler = window.onerror;
window.onerror = function abOnerror() {
if (oldHandler) {
oldHandler.apply(this, arguments);
}
self.onerror.apply(self, arguments);
};
}
instrumentDOM(this);
if (enabled(opt.fetch) && typeof fetch === 'function') {
instrumentFetch(this);
}
if (enabled(opt.history) && typeof history === 'object') {
instrumentLocation(this);
}
if (enabled(opt.console) && typeof console === 'object') {
instrumentConsole(this);
}
if (enabled(opt.xhr) && typeof XMLHttpRequest !== 'undefined') {
instrumentXHR(this);
}
if (
enabled(opt.unhandledrejection) &&
typeof addEventListener === 'function'
) {
instrumentUnhandledrejection(this);
}
}
public notify(err: any): Promise {
if (this.offline) {
return new Promise((resolve, reject) => {
this.todo.push({
err,
resolve,
reject,
});
while (this.todo.length > 100) {
let j = this.todo.shift();
if (j === undefined) {
break;
}
j.resolve({
error: new Error('airbrake: offline queue is too large'),
});
}
});
}
return super.notify(err);
}
protected onOnline(): void {
this.offline = false;
for (let j of this.todo) {
this.notify(j.err).then((notice) => {
j.resolve(notice);
});
}
this.todo = [];
}
protected onOffline(): void {
this.offline = true;
}
onerror(
message: string,
filename?: string,
line?: number,
column?: number,
err?: Error
): void {
if (this._ignoreWindowError > 0) {
return;
}
if (err) {
this.notify({
error: err,
context: {
windowError: true,
},
});
return;
}
// Ignore errors without file or line.
if (!filename || !line) {
return;
}
this.notify({
error: {
message,
fileName: filename,
lineNumber: line,
columnNumber: column,
noStack: true,
},
context: {
windowError: true,
},
});
}
_ignoreNextWindowError(): void {
this._ignoreWindowError++;
setTimeout(() => this._ignoreWindowError--);
}
}
function isDevEnv(env: any): boolean {
return env && env.startsWith && env.startsWith('dev');
}
function enabled(v: undefined | boolean): boolean {
return v === undefined || v === true;
}
================================================
FILE: packages/browser/src/options.ts
================================================
import * as request from 'request';
import { INotice } from './notice';
import { Processor } from './processor/processor';
type Reporter = (notice: INotice) => Promise;
export interface IInstrumentationOptions {
onerror?: boolean;
fetch?: boolean;
history?: boolean;
console?: boolean;
xhr?: boolean;
unhandledrejection?: boolean;
}
export interface IOptions {
projectId: number;
projectKey: string;
environment?: string;
host?: string;
apmHost?: string;
remoteConfigHost?: string;
remoteConfig?: boolean;
timeout?: number;
keysBlocklist?: any[];
processor?: Processor;
reporter?: Reporter;
instrumentation?: IInstrumentationOptions;
errorNotifications?: boolean;
performanceStats?: boolean;
queryStats?: boolean;
queueStats?: boolean;
request?: request.RequestAPI<
request.Request,
request.CoreOptions,
request.RequiredUriUrl
>;
}
================================================
FILE: packages/browser/src/processor/esp.ts
================================================
import { INoticeError, INoticeFrame } from '../notice';
import ErrorStackParser from 'error-stack-parser';
const hasConsole = typeof console === 'object' && console.warn;
export interface IStackFrame {
functionName?: string;
fileName?: string;
lineNumber?: number;
columnNumber?: number;
}
export interface IError extends Error, IStackFrame {
noStack?: boolean;
}
function parse(err: IError): IStackFrame[] {
try {
return ErrorStackParser.parse(err);
} catch (parseErr) {
if (hasConsole && err.stack) {
console.warn('ErrorStackParser:', parseErr.toString(), err.stack);
}
}
if (err.fileName) {
return [err];
}
return [];
}
export function espProcessor(err: IError): INoticeError {
let backtrace: INoticeFrame[] = [];
if (err.noStack) {
backtrace.push({
function: err.functionName || '',
file: err.fileName || '',
line: err.lineNumber || 0,
column: err.columnNumber || 0,
});
} else {
let frames = parse(err);
if (frames.length === 0) {
try {
throw new Error('fake');
} catch (fakeErr) {
frames = parse(fakeErr);
frames.shift();
frames.shift();
}
}
for (let frame of frames) {
backtrace.push({
function: frame.functionName || '',
file: frame.fileName || '',
line: frame.lineNumber || 0,
column: frame.columnNumber || 0,
});
}
}
let type: string = err.name ? err.name : '';
let msg: string = err.message ? String(err.message) : String(err);
return {
type,
message: msg,
backtrace,
};
}
================================================
FILE: packages/browser/src/processor/processor.ts
================================================
import { INoticeError } from '../notice';
export type Processor = (err: Error) => INoticeError;
================================================
FILE: packages/browser/src/queries.ts
================================================
import { makeRequester, Requester } from './http_req';
import { IOptions } from './options';
import { hasTdigest, TDigestStat } from './tdshared';
const FLUSH_INTERVAL = 15000; // 15 seconds
interface IQueryKey {
method: string;
route: string;
query: string;
func: string;
file: string;
line: number;
time: Date;
}
export class QueryInfo {
method = '';
route = '';
query = '';
func = '';
file = '';
line = 0;
startTime = new Date();
endTime: Date;
constructor(query = '') {
this.query = query;
}
_duration(): number {
if (!this.endTime) {
this.endTime = new Date();
}
return this.endTime.getTime() - this.startTime.getTime();
}
}
export class QueriesStats {
_opt: IOptions;
_url: string;
_requester: Requester;
_m: { [key: string]: TDigestStat } = {};
_timer;
constructor(opt: IOptions) {
this._opt = opt;
this._url = `${opt.host}/api/v5/projects/${opt.projectId}/queries-stats?key=${opt.projectKey}`;
this._requester = makeRequester(opt);
}
start(query = ''): QueryInfo {
return new QueryInfo(query);
}
notify(q: QueryInfo): void {
if (!hasTdigest) {
return;
}
if (!this._opt.performanceStats) {
return;
}
if (!this._opt.queryStats) {
return;
}
let ms = q._duration();
const minute = 60 * 1000;
let startTime = new Date(
Math.floor(q.startTime.getTime() / minute) * minute
);
let key: IQueryKey = {
method: q.method,
route: q.route,
query: q.query,
func: q.func,
file: q.file,
line: q.line,
time: startTime,
};
let keyStr = JSON.stringify(key);
let stat = this._m[keyStr];
if (!stat) {
stat = new TDigestStat();
this._m[keyStr] = stat;
}
stat.add(ms);
if (this._timer) {
return;
}
this._timer = setTimeout(() => {
this._flush();
}, FLUSH_INTERVAL);
}
_flush(): void {
let queries = [];
for (let keyStr in this._m) {
if (!this._m.hasOwnProperty(keyStr)) {
continue;
}
let key: IQueryKey = JSON.parse(keyStr);
let v = {
...key,
...this._m[keyStr].toJSON(),
};
queries.push(v);
}
this._m = {};
this._timer = null;
let outJSON = JSON.stringify({
environment: this._opt.environment,
queries,
});
let req = {
method: 'POST',
url: this._url,
body: outJSON,
};
this._requester(req)
.then((_resp) => {
// nothing
})
.catch((err) => {
if (console.error) {
console.error('can not report queries stats', err);
}
});
}
}
================================================
FILE: packages/browser/src/queues.ts
================================================
import { makeRequester, Requester } from './http_req';
import { BaseMetric } from './metrics';
import { IOptions } from './options';
import { hasTdigest, TDigestStatGroups } from './tdshared';
const FLUSH_INTERVAL = 15000; // 15 seconds
interface IQueueKey {
queue: string;
time: Date;
}
export class QueueMetric extends BaseMetric {
queue: string;
constructor(queue: string) {
super();
this.queue = queue;
this.startTime = new Date();
}
}
export class QueuesStats {
_opt: IOptions;
_url: string;
_requester: Requester;
_m: { [key: string]: TDigestStatGroups } = {};
_timer;
constructor(opt: IOptions) {
this._opt = opt;
this._url = `${opt.host}/api/v5/projects/${opt.projectId}/queues-stats?key=${opt.projectKey}`;
this._requester = makeRequester(opt);
}
notify(q: QueueMetric): void {
if (!hasTdigest) {
return;
}
if (!this._opt.performanceStats) {
return;
}
if (!this._opt.queueStats) {
return;
}
let ms = q._duration();
if (ms === 0) {
ms = 0.00001;
}
const minute = 60 * 1000;
let startTime = new Date(
Math.floor(q.startTime.getTime() / minute) * minute
);
let key: IQueueKey = {
queue: q.queue,
time: startTime,
};
let keyStr = JSON.stringify(key);
let stat = this._m[keyStr];
if (!stat) {
stat = new TDigestStatGroups();
this._m[keyStr] = stat;
}
stat.addGroups(ms, q._groups);
if (this._timer) {
return;
}
this._timer = setTimeout(() => {
this._flush();
}, FLUSH_INTERVAL);
}
_flush(): void {
let queues = [];
for (let keyStr in this._m) {
if (!this._m.hasOwnProperty(keyStr)) {
continue;
}
let key: IQueueKey = JSON.parse(keyStr);
let v = {
...key,
...this._m[keyStr].toJSON(),
};
queues.push(v);
}
this._m = {};
this._timer = null;
let outJSON = JSON.stringify({
environment: this._opt.environment,
queues,
});
let req = {
method: 'POST',
url: this._url,
body: outJSON,
};
this._requester(req)
.then((_resp) => {
// nothing
})
.catch((err) => {
if (console.error) {
console.error('can not report queues breakdowns', err);
}
});
}
}
================================================
FILE: packages/browser/src/remote_settings.ts
================================================
import { makeRequester, Requester } from './http_req';
import { IOptions } from './options';
import { NOTIFIER_NAME, NOTIFIER_VERSION } from './version';
// API version to poll.
const API_VER = '2020-06-18';
// How frequently we should poll the config API.
const DEFAULT_INTERVAL = 600000; // 10 minutes
const NOTIFIER_INFO = {
notifier_name: NOTIFIER_NAME,
notifier_version: NOTIFIER_VERSION,
os:
typeof window !== 'undefined' &&
window.navigator &&
window.navigator.userAgent
? window.navigator.userAgent
: undefined,
language: 'JavaScript',
};
// Remote config settings.
const ERROR_SETTING = 'errors';
const APM_SETTING = 'apm';
interface IRemoteConfig {
project_id: number;
updated_at: number;
poll_sec: number;
config_route: string;
settings: IRemoteConfigSetting[];
}
interface IRemoteConfigSetting {
name: string;
enabled: boolean;
endpoint: string;
}
type Entries = {
[K in keyof T]: [K, T[K]];
}[keyof T][];
export class RemoteSettings {
_opt: IOptions;
_requester: Requester;
_data: SettingsData;
_origErrorNotifications: boolean;
_origPerformanceStats: boolean;
constructor(opt: IOptions) {
this._opt = opt;
this._requester = makeRequester(opt);
this._data = new SettingsData(opt.projectId, {
project_id: null,
poll_sec: 0,
updated_at: 0,
config_route: '',
settings: [],
});
this._origErrorNotifications = opt.errorNotifications;
this._origPerformanceStats = opt.performanceStats;
}
poll(): any {
// First request is immediate. When it's done, we cancel it since we want to
// change interval time to the default value.
const pollerId = setInterval(() => {
this._doRequest();
clearInterval(pollerId);
}, 0);
// Second fetch is what always runs in background.
return setInterval(this._doRequest.bind(this), DEFAULT_INTERVAL);
}
_doRequest(): void {
this._requester(this._requestParams(this._opt))
.then((resp) => {
this._data.merge(resp.json);
this._opt.host = this._data.errorHost();
this._opt.apmHost = this._data.apmHost();
this._processErrorNotifications(this._data);
this._processPerformanceStats(this._data);
})
.catch((_) => {
return;
});
}
_requestParams(opt: IOptions): any {
return {
method: 'GET',
url: this._pollUrl(opt),
headers: {
Accept: 'application/json',
'Cache-Control': 'no-cache,no-store',
},
};
}
_pollUrl(opt: IOptions): string {
const url = this._data.configRoute(opt.remoteConfigHost);
let queryParams = '?';
for (const [key, value] of this._entries(NOTIFIER_INFO)) {
queryParams += `&${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
}
return url + queryParams;
}
_processErrorNotifications(data: SettingsData): void {
if (!this._origErrorNotifications) {
return;
}
this._opt.errorNotifications = data.errorNotifications();
}
_processPerformanceStats(data: SettingsData): void {
if (!this._origPerformanceStats) {
return;
}
this._opt.performanceStats = data.performanceStats();
}
// Polyfill from:
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/entries#polyfill
_entries(obj: T): Entries {
const ownProps = Object.keys(obj);
let i = ownProps.length;
const resArray = new Array(i);
while (i--) resArray[i] = [ownProps[i], obj[ownProps[i]]];
return resArray;
}
}
export class SettingsData {
_projectId: number;
_data: IRemoteConfig;
constructor(projectId: number, data: IRemoteConfig) {
this._projectId = projectId;
this._data = data;
}
merge(other: IRemoteConfig) {
this._data = { ...this._data, ...other };
}
configRoute(remoteConfigHost: string): string {
const host = remoteConfigHost.replace(/\/$/, '');
const configRoute = this._data.config_route;
if (
configRoute === null ||
configRoute === undefined ||
configRoute === ''
) {
return `${host}/${API_VER}/config/${this._projectId}/config.json`;
} else {
return `${host}/${configRoute}`;
}
}
errorNotifications(): boolean {
const s = this._findSetting(ERROR_SETTING);
if (s === null) {
return true;
}
return s.enabled;
}
performanceStats(): boolean {
const s = this._findSetting(APM_SETTING);
if (s === null) {
return true;
}
return s.enabled;
}
errorHost(): string {
const s = this._findSetting(ERROR_SETTING);
if (s === null) {
return null;
}
return s.endpoint;
}
apmHost(): string {
const s = this._findSetting(APM_SETTING);
if (s === null) {
return null;
}
return s.endpoint;
}
_findSetting(name: string): IRemoteConfigSetting {
const settings = this._data.settings;
if (settings === null || settings === undefined) {
return null;
}
const setting = settings.find((s) => {
return s.name === name;
});
if (setting === undefined) {
return null;
}
return setting;
}
}
================================================
FILE: packages/browser/src/routes.ts
================================================
import { makeRequester, Requester } from './http_req';
import { BaseMetric } from './metrics';
import { IOptions } from './options';
import { hasTdigest, TDigestStat, TDigestStatGroups } from './tdshared';
const FLUSH_INTERVAL = 15000; // 15 seconds
interface IRouteKey {
method: string;
route: string;
statusCode: number;
time: Date;
}
interface IBreakdownKey {
method: string;
route: string;
responseType: string;
time: Date;
}
export class RouteMetric extends BaseMetric {
method: string;
route: string;
statusCode: number;
contentType: string;
constructor(method = '', route = '', statusCode = 0, contentType = '') {
super();
this.method = method;
this.route = route;
this.statusCode = statusCode;
this.contentType = contentType;
this.startTime = new Date();
}
}
export class RoutesStats {
_opt: IOptions;
_url: string;
_requester: Requester;
_m: { [key: string]: TDigestStat } = {};
_timer;
constructor(opt: IOptions) {
this._opt = opt;
this._url = `${opt.host}/api/v5/projects/${opt.projectId}/routes-stats?key=${opt.projectKey}`;
this._requester = makeRequester(opt);
}
notify(req: RouteMetric): void {
if (!hasTdigest) {
return;
}
if (!this._opt.performanceStats) {
return;
}
let ms = req._duration();
const minute = 60 * 1000;
let startTime = new Date(
Math.floor(req.startTime.getTime() / minute) * minute
);
let key: IRouteKey = {
method: req.method,
route: req.route,
statusCode: req.statusCode,
time: startTime,
};
let keyStr = JSON.stringify(key);
let stat = this._m[keyStr];
if (!stat) {
stat = new TDigestStat();
this._m[keyStr] = stat;
}
stat.add(ms);
if (this._timer) {
return;
}
this._timer = setTimeout(() => {
this._flush();
}, FLUSH_INTERVAL);
}
_flush(): void {
let routes = [];
for (let keyStr in this._m) {
if (!this._m.hasOwnProperty(keyStr)) {
continue;
}
let key: IRouteKey = JSON.parse(keyStr);
let v = {
...key,
...this._m[keyStr].toJSON(),
};
routes.push(v);
}
this._m = {};
this._timer = null;
let outJSON = JSON.stringify({
environment: this._opt.environment,
routes,
});
let req = {
method: 'POST',
url: this._url,
body: outJSON,
};
this._requester(req)
.then((_resp) => {
// nothing
})
.catch((err) => {
if (console.error) {
console.error('can not report routes stats', err);
}
});
}
}
export class RoutesBreakdowns {
_opt: IOptions;
_url: string;
_requester: Requester;
_m: { [key: string]: TDigestStatGroups } = {};
_timer;
constructor(opt: IOptions) {
this._opt = opt;
this._url = `${opt.host}/api/v5/projects/${opt.projectId}/routes-breakdowns?key=${opt.projectKey}`;
this._requester = makeRequester(opt);
}
notify(req: RouteMetric): void {
if (!hasTdigest) {
return;
}
if (!this._opt.performanceStats) {
return;
}
if (
req.statusCode < 200 ||
(req.statusCode >= 300 && req.statusCode < 400) ||
req.statusCode === 404 ||
Object.keys(req._groups).length === 0
) {
return;
}
let ms = req._duration();
if (ms === 0) {
ms = 0.00001;
}
const minute = 60 * 1000;
let startTime = new Date(
Math.floor(req.startTime.getTime() / minute) * minute
);
let key: IBreakdownKey = {
method: req.method,
route: req.route,
responseType: this._responseType(req),
time: startTime,
};
let keyStr = JSON.stringify(key);
let stat = this._m[keyStr];
if (!stat) {
stat = new TDigestStatGroups();
this._m[keyStr] = stat;
}
stat.addGroups(ms, req._groups);
if (this._timer) {
return;
}
this._timer = setTimeout(() => {
this._flush();
}, FLUSH_INTERVAL);
}
_flush(): void {
let routes = [];
for (let keyStr in this._m) {
if (!this._m.hasOwnProperty(keyStr)) {
continue;
}
let key: IBreakdownKey = JSON.parse(keyStr);
let v = {
...key,
...this._m[keyStr].toJSON(),
};
routes.push(v);
}
this._m = {};
this._timer = null;
let outJSON = JSON.stringify({
environment: this._opt.environment,
routes,
});
let req = {
method: 'POST',
url: this._url,
body: outJSON,
};
this._requester(req)
.then((_resp) => {
// nothing
})
.catch((err) => {
if (console.error) {
console.error('can not report routes breakdowns', err);
}
});
}
_responseType(req: RouteMetric): string {
if (req.statusCode >= 500) {
return '5xx';
}
if (req.statusCode >= 400) {
return '4xx';
}
if (!req.contentType) {
return '';
}
const s = req.contentType.split(';')[0].split('/');
return s[s.length - 1];
}
}
================================================
FILE: packages/browser/src/scope.ts
================================================
import { IMetric, NoopMetric } from './metrics';
interface IHistoryRecord {
type: string;
date?: Date;
[key: string]: any;
}
interface IMap {
[key: string]: any;
}
export class Scope {
_noopMetric = new NoopMetric();
_routeMetric: IMetric;
_queueMetric: IMetric;
_context: IMap = {};
_historyMaxLen = 20;
_history: IHistoryRecord[] = [];
_lastRecord: IHistoryRecord;
clone(): Scope {
const clone = new Scope();
clone._context = { ...this._context };
clone._history = this._history.slice();
return clone;
}
setContext(context: IMap) {
this._context = { ...this._context, ...context };
}
context(): IMap {
const ctx = { ...this._context };
if (this._history.length > 0) {
ctx.history = this._history.slice();
}
return ctx;
}
pushHistory(state: IHistoryRecord): void {
if (this._isDupState(state)) {
if (this._lastRecord.num) {
this._lastRecord.num++;
} else {
this._lastRecord.num = 2;
}
return;
}
if (!state.date) {
state.date = new Date();
}
this._history.push(state);
this._lastRecord = state;
if (this._history.length > this._historyMaxLen) {
this._history = this._history.slice(-this._historyMaxLen);
}
}
private _isDupState(state): boolean {
if (!this._lastRecord) {
return false;
}
for (let key in state) {
if (!state.hasOwnProperty(key) || key === 'date') {
continue;
}
if (state[key] !== this._lastRecord[key]) {
return false;
}
}
return true;
}
routeMetric(): IMetric {
return this._routeMetric || this._noopMetric;
}
setRouteMetric(metric: IMetric) {
this._routeMetric = metric;
}
queueMetric(): IMetric {
return this._queueMetric || this._noopMetric;
}
setQueueMetric(metric: IMetric) {
this._queueMetric = metric;
}
}
================================================
FILE: packages/browser/src/tdshared.ts
================================================
let tdigest;
export let hasTdigest = false;
try {
tdigest = require('tdigest');
hasTdigest = true;
} catch (err) {}
interface ICentroid {
mean: number;
n: number;
}
interface ICentroids {
each(fn: (c: ICentroid) => void): void;
}
interface ITDigest {
centroids: ICentroids;
push(x: number);
compress();
}
interface ITDigestCentroids {
mean: number[];
count: number[];
}
export class TDigestStat {
count = 0;
sum = 0;
sumsq = 0;
_td = new tdigest.Digest();
add(ms: number) {
if (ms === 0) {
ms = 0.00001;
}
this.count += 1;
this.sum += ms;
this.sumsq += ms * ms;
if (this._td) {
this._td.push(ms);
}
}
toJSON() {
return {
count: this.count,
sum: this.sum,
sumsq: this.sumsq,
tdigestCentroids: tdigestCentroids(this._td),
};
}
}
export class TDigestStatGroups extends TDigestStat {
groups: { [key: string]: TDigestStat } = {};
addGroups(totalMs: number, groups: { [key: string]: number }) {
this.add(totalMs);
for (const name in groups) {
if (groups.hasOwnProperty(name)) {
this.addGroup(name, groups[name]);
}
}
}
addGroup(name: string, ms: number) {
let stat = this.groups[name];
if (!stat) {
stat = new TDigestStat();
this.groups[name] = stat;
}
stat.add(ms);
}
toJSON() {
return {
count: this.count,
sum: this.sum,
sumsq: this.sumsq,
tdigestCentroids: tdigestCentroids(this._td),
groups: this.groups,
};
}
}
function tdigestCentroids(td: ITDigest): ITDigestCentroids {
let means: number[] = [];
let counts: number[] = [];
td.centroids.each((c: ICentroid) => {
means.push(c.mean);
counts.push(c.n);
});
return {
mean: means,
count: counts,
};
}
================================================
FILE: packages/browser/src/version.ts
================================================
export const NOTIFIER_NAME = 'airbrake-js/browser';
export const NOTIFIER_VERSION = '2.1.9';
export const NOTIFIER_URL =
'https://github.com/airbrake/airbrake-js/tree/master/packages/browser';
================================================
FILE: packages/browser/tests/client.test.js
================================================
import { Notifier } from '../src/notifier';
describe('Notifier config', () => {
const reporter = jest.fn(() => Promise.resolve({ errors: [] }));
const err = new Error('test');
let client;
test('throws when projectId or projectKey are missing', () => {
expect(() => {
new Notifier({});
}).toThrow('airbrake: projectId and projectKey are required');
});
test('calls a reporter', () => {
client = new Notifier({
projectId: 1,
projectKey: 'abc',
reporter,
remoteConfig: false,
});
client.notify(err);
expect(reporter.mock.calls.length).toBe(1);
});
test('supports environment', () => {
client = new Notifier({
projectId: 1,
projectKey: 'abc',
reporter,
environment: 'production',
remoteConfig: false,
});
client.notify(err);
expect(reporter.mock.calls.length).toBe(1);
let notice = reporter.mock.calls[0][0];
expect(notice.context.environment).toBe('production');
});
describe('keysBlocklist', () => {
function test(keysBlocklist) {
client = new Notifier({
projectId: 1,
projectKey: 'abc',
reporter,
keysBlocklist,
remoteConfig: false,
});
client.notify({
error: err,
params: {
key1: 'value1',
key2: 'value2',
key3: { key1: 'value1' },
},
});
expect(reporter.mock.calls.length).toBe(1);
let notice = reporter.mock.calls[0][0];
expect(notice.params).toStrictEqual({
key1: '[Filtered]',
key2: 'value2',
key3: { key1: '[Filtered]' },
});
}
it('supports exact match', () => {
test(['key1']);
});
it('supports regexp match', () => {
test([/key1/]);
});
});
});
describe('Notifier', () => {
let reporter;
let client;
let theErr = new Error('test');
beforeEach(() => {
reporter = jest.fn(() => {
return Promise.resolve({ id: 1 });
});
client = new Notifier({
projectId: 1,
projectKey: 'abc',
reporter,
remoteConfig: false,
});
});
describe('filter', () => {
it('returns null to ignore notice', () => {
let filter = jest.fn((_) => null);
client.addFilter(filter);
client.notify({});
expect(filter.mock.calls.length).toBe(1);
expect(reporter.mock.calls.length).toBe(0);
});
it('returns notice to keep it', () => {
let filter = jest.fn((notice) => notice);
client.addFilter(filter);
client.notify({});
expect(filter.mock.calls.length).toBe(1);
expect(reporter.mock.calls.length).toBe(1);
});
it('returns notice to change payload', () => {
let filter = jest.fn((notice) => {
notice.context.environment = 'production';
return notice;
});
client.addFilter(filter);
client.notify({});
expect(filter.mock.calls.length).toBe(1);
let notice = reporter.mock.calls[0][0];
expect(notice.context.environment).toBe('production');
});
it('returns new notice to change payload', () => {
let newNotice = { errors: [] };
let filter = jest.fn((_) => {
return newNotice;
});
client.addFilter(filter);
client.notify({});
expect(filter.mock.calls.length).toBe(1);
let notice = reporter.mock.calls[0][0];
expect(notice).toEqual(newNotice);
});
});
describe('"Uncaught ..." error message', () => {
beforeEach(() => {
let msg =
'Uncaught SecurityError: Blocked a frame with origin "https://airbrake.io" from accessing a cross-origin frame.';
client.notify({ type: '', message: msg });
});
it('splitted into type and message', () => {
expect(reporter.mock.calls.length).toBe(1);
let notice = reporter.mock.calls[0][0];
let err = notice.errors[0];
expect(err.type).toBe('SecurityError');
expect(err.message).toBe(
'Blocked a frame with origin "https://airbrake.io" from accessing a cross-origin frame.'
);
});
});
describe('Angular error message', () => {
beforeEach(() => {
let msg = `[$injector:undef] Provider '$exceptionHandler' must return a value from $get factory method.\nhttp://errors.angularjs.org/1.4.3/$injector/undef?p0=%24exceptionHandler`;
client.notify({ type: 'Error', message: msg });
});
it('splitted into type and message', () => {
expect(reporter.mock.calls.length).toBe(1);
let notice = reporter.mock.calls[0][0];
let err = notice.errors[0];
expect(err.type).toBe('$injector:undef');
expect(err.message).toBe(
`Provider '$exceptionHandler' must return a value from $get factory method.\nhttp://errors.angularjs.org/1.4.3/$injector/undef?p0=%24exceptionHandler`
);
});
});
describe('severity', () => {
it('defaults to "error"', () => {
client.notify(theErr);
let reported = reporter.mock.calls[0][0];
expect(reported.context.severity).toBe('error');
});
it('can be overriden', () => {
let customSeverity = 'emergency';
client.addFilter((n) => {
n.context.severity = customSeverity;
return n;
});
client.notify(theErr);
let reported = reporter.mock.calls[0][0];
expect(reported.context.severity).toBe(customSeverity);
});
});
describe('notify', () => {
it('calls reporter', () => {
client.notify(theErr);
expect(reporter.mock.calls.length).toBe(1);
});
describe('when errorNotifications is disabled', () => {
beforeEach(() => {
client = new Notifier({
projectId: 1,
projectKey: 'abc',
reporter,
environment: 'production',
errorNotifications: false,
remoteConfig: false,
});
});
it('does not call reporter', () => {
client.notify(theErr);
expect(reporter.mock.calls.length).toBe(0);
});
it('returns promise and resolves it', (done) => {
let promise = client.notify(theErr);
let onResolved = jest.fn();
promise.then(onResolved);
setTimeout(() => {
expect(onResolved.mock.calls.length).toBe(1);
done();
}, 0);
});
});
it('returns promise and resolves it', (done) => {
let promise = client.notify(theErr);
let onResolved = jest.fn();
promise.then(onResolved);
setTimeout(() => {
expect(onResolved.mock.calls.length).toBe(1);
done();
}, 0);
});
it('does not report same error twice', (done) => {
client.notify(theErr);
expect(reporter.mock.calls.length).toBe(1);
let promise = client.notify(theErr);
promise.then((notice) => {
expect(notice.error.toString()).toBe(
'Error: airbrake: error is filtered'
);
done();
});
});
it('reports NaN errors', () => {
client.notify(NaN);
expect(reporter.mock.calls.length).toEqual(1);
let notice = reporter.mock.calls[0][0];
expect(notice.errors[0].message).toEqual('NaN');
});
it('reports undefined errors', () => {
client.notify(undefined);
expect(reporter.mock.calls.length).toEqual(1);
let notice = reporter.mock.calls[0][0];
expect(notice.errors[0].message).toEqual('undefined');
});
it('reports empty string errors', () => {
client.notify('');
expect(reporter.mock.calls.length).toEqual(1);
let notice = reporter.mock.calls[0][0];
expect(notice.errors[0].message).toEqual('');
});
it('reports "false"', () => {
client.notify(false);
expect(reporter.mock.calls.length).toEqual(1);
let notice = reporter.mock.calls[0][0];
expect(notice.errors[0].message).toEqual('false');
});
it('reports "null"', () => {
client.notify(null);
expect(reporter.mock.calls.length).toEqual(1);
let notice = reporter.mock.calls[0][0];
expect(notice.errors[0].message).toEqual('null');
});
it('reports severity', () => {
client.notify({ error: theErr, context: { severity: 'warning' } });
let notice = reporter.mock.calls[0][0];
expect(notice.context.severity).toBe('warning');
});
it('reports userAgent', () => {
client.notify(theErr);
let notice = reporter.mock.calls[0][0];
expect(notice.context.userAgent).toContain('Mozilla');
});
it('reports text error', () => {
client.notify('hello');
expect(reporter.mock.calls.length).toBe(1);
let notice = reporter.mock.calls[0][0];
let err = notice.errors[0];
expect(err.message).toBe('hello');
expect(err.backtrace.length).not.toBe(0);
});
it('ignores "Script error" message', () => {
client.notify('Script error');
expect(reporter.mock.calls.length).toBe(0);
});
it('ignores "InvalidAccessError" message', () => {
client.notify('InvalidAccessError');
expect(reporter.mock.calls.length).toBe(0);
});
it('ignores errors occurred in file', () => {
client.notify({ message: 'test', fileName: '' });
expect(reporter.mock.calls.length).toBe(0);
});
describe('custom data in the filter', () => {
it('reports context', () => {
client.addFilter((n) => {
n.context.context_key = '[custom_context]';
return n;
});
client.notify(theErr);
let reported = reporter.mock.calls[0][0];
expect(reported.context.context_key).toEqual('[custom_context]');
});
it('reports environment', () => {
client.addFilter((n) => {
n.environment.env_key = '[custom_env]';
return n;
});
client.notify(theErr);
let reported = reporter.mock.calls[0][0];
expect(reported.environment.env_key).toEqual('[custom_env]');
});
it('reports params', () => {
client.addFilter((n) => {
n.params.params_key = '[custom_params]';
return n;
});
client.notify(theErr);
let reported = reporter.mock.calls[0][0];
expect(reported.params.params_key).toEqual('[custom_params]');
});
it('reports session', () => {
client.addFilter((n) => {
n.session.session_key = '[custom_session]';
return n;
});
client.notify(theErr);
let reported = reporter.mock.calls[0][0];
expect(reported.session.session_key).toEqual('[custom_session]');
});
});
describe('wrapped error', () => {
it('unwraps and processes error', () => {
client.notify({ error: theErr });
expect(reporter.mock.calls.length).toBe(1);
});
it('reports NaN errors', () => {
client.notify({ error: NaN });
expect(reporter.mock.calls.length).toEqual(1);
let notice = reporter.mock.calls[0][0];
expect(notice.errors[0].message).toEqual('NaN');
});
it('reports undefined errors', () => {
client.notify({ error: undefined });
expect(reporter.mock.calls.length).toEqual(1);
let notice = reporter.mock.calls[0][0];
expect(notice.errors[0].message).toEqual('undefined');
});
it('reports empty string errors', () => {
client.notify({ error: '' });
expect(reporter.mock.calls.length).toEqual(1);
let notice = reporter.mock.calls[0][0];
expect(notice.errors[0].message).toEqual('');
});
it('reports "false"', () => {
client.notify({ error: false });
expect(reporter.mock.calls.length).toEqual(1);
let notice = reporter.mock.calls[0][0];
expect(notice.errors[0].message).toEqual('false');
});
it('reports "null"', () => {
client.notify({ error: null });
expect(reporter.mock.calls.length).toEqual(1);
let notice = reporter.mock.calls[0][0];
expect(notice.errors[0].message).toEqual('null');
});
it('reports custom context', () => {
client.addFilter((n) => {
n.context.context1 = 'value1';
n.context.context2 = 'value2';
return n;
});
client.notify({
error: theErr,
context: {
context1: 'notify_value1',
context3: 'notify_value3',
},
});
let reported = reporter.mock.calls[0][0];
expect(reported.context.context1).toBe('value1');
expect(reported.context.context2).toBe('value2');
expect(reported.context.context3).toBe('notify_value3');
});
it('reports custom environment', () => {
client.addFilter((n) => {
n.environment.env1 = 'value1';
n.environment.env2 = 'value2';
return n;
});
client.notify({
error: theErr,
environment: {
env1: 'notify_value1',
env3: 'notify_value3',
},
});
let reported = reporter.mock.calls[0][0];
expect(reported.environment).toStrictEqual({
env1: 'value1',
env2: 'value2',
env3: 'notify_value3',
});
});
it('reports custom params', () => {
client.addFilter((n) => {
n.params.param1 = 'value1';
n.params.param2 = 'value2';
return n;
});
client.notify({
error: theErr,
params: {
param1: 'notify_value1',
param3: 'notify_value3',
},
});
let params = reporter.mock.calls[0][0].params;
expect(params.param1).toBe('value1');
expect(params.param2).toBe('value2');
expect(params.param3).toBe('notify_value3');
});
it('reports custom session', () => {
client.addFilter((n) => {
n.session.session1 = 'value1';
n.session.session2 = 'value2';
return n;
});
client.notify({
error: theErr,
session: {
session1: 'notify_value1',
session3: 'notify_value3',
},
});
let reported = reporter.mock.calls[0][0];
expect(reported.session).toStrictEqual({
session1: 'value1',
session2: 'value2',
session3: 'notify_value3',
});
});
});
});
describe('location', () => {
let notice;
beforeEach(() => {
client.notify(theErr);
expect(reporter.mock.calls.length).toBe(1);
notice = reporter.mock.calls[0][0];
});
it('reports context.url', () => {
expect(notice.context.url).toEqual('http://localhost/');
});
it('reports context.rootDirectory', () => {
expect(notice.context.rootDirectory).toEqual('http://localhost');
});
});
describe('wrap', () => {
it('does not invoke function immediately', () => {
let fn = jest.fn();
client.wrap(fn);
expect(fn.mock.calls.length).toBe(0);
});
it('creates wrapper that invokes function with passed args', () => {
let fn = jest.fn();
let wrapper = client.wrap(fn);
wrapper('hello', 'world');
expect(fn.mock.calls.length).toBe(1);
expect(fn.mock.calls[0]).toEqual(['hello', 'world']);
});
it('sets _airbrake and inner properties', () => {
let fn = jest.fn();
let wrapper = client.wrap(fn);
expect(wrapper._airbrake).toEqual(true);
expect(wrapper.inner).toEqual(fn);
});
it('copies function properties', () => {
let fn = jest.fn();
fn.prop = 'hello';
let wrapper = client.wrap(fn);
expect(wrapper.prop).toEqual('hello');
});
it('reports throwed exception', () => {
let spy = jest.fn();
client.notify = spy;
let fn = () => {
throw theErr;
};
let wrapper = client.wrap(fn);
try {
wrapper('hello', 'world');
} catch (_) {
// ignore
}
expect(spy.mock.calls.length).toBe(1);
expect(spy.mock.calls[0]).toEqual([
{
error: theErr,
params: { arguments: ['hello', 'world'] },
},
]);
});
it('wraps arguments', () => {
let fn = jest.fn();
let wrapper = client.wrap(fn);
let arg1 = () => null;
wrapper(arg1);
expect(fn.mock.calls.length).toBe(1);
let arg1Wrapper = fn.mock.calls[0][0];
expect(arg1Wrapper._airbrake).toEqual(true);
expect(arg1Wrapper.inner).toEqual(arg1);
});
});
describe('call', () => {
it('reports throwed exception', () => {
let spy = jest.fn();
client.notify = spy;
let fn = () => {
throw theErr;
};
try {
client.call(fn, 'hello', 'world');
} catch (_) {
// ignore
}
expect(spy.mock.calls.length).toBe(1);
expect(spy.mock.calls[0]).toEqual([
{
error: theErr,
params: { arguments: ['hello', 'world'] },
},
]);
});
});
describe('offline', () => {
let spy;
beforeEach(() => {
let event = new Event('offline');
window.dispatchEvent(event);
let promise = client.notify(theErr);
spy = jest.fn();
promise.then(spy);
});
it('causes client to not report errors', () => {
expect(reporter.mock.calls.length).toBe(0);
});
describe('online', () => {
beforeEach(() => {
let event = new Event('online');
window.dispatchEvent(event);
});
it('causes client to report queued errors', () => {
expect(reporter.mock.calls.length).toBe(1);
});
it('resolves promise', (done) => {
setTimeout(() => {
expect(spy.mock.calls.length).toBe(1);
done();
}, 0);
});
});
});
describe('errorNotifications', () => {
it('is set to true by default when it is not specified', () => {
client = new Notifier({
projectId: 1,
projectKey: 'abc',
remoteConfig: false,
});
expect(client._opt.errorNotifications).toBe(true);
});
});
describe('performanceStats', () => {
it('is set to true by default when it is not specified', () => {
client = new Notifier({
projectId: 1,
projectKey: 'abc',
remoteConfig: false,
});
expect(client._opt.performanceStats).toBe(true);
});
});
describe('queryStats', () => {
it('is set to true by default when it is not specified', () => {
client = new Notifier({
projectId: 1,
projectKey: 'abc',
remoteConfig: false,
});
expect(client._opt.queryStats).toBe(true);
});
});
describe('queueStats', () => {
it('is set to true by default when it is not specified', () => {
client = new Notifier({
projectId: 1,
projectKey: 'abc',
remoteConfig: false,
});
expect(client._opt.queueStats).toBe(true);
});
});
});
================================================
FILE: packages/browser/tests/historian.test.js
================================================
let { fetch, Request } = require('cross-fetch');
window.fetch = fetch;
import { Notifier } from '../src/notifier';
class Location {
constructor(s) {
this.s = s;
}
toString() {
return this.s;
}
}
describe('instrumentation', () => {
let processor;
let reporter;
let client;
beforeEach(() => {
processor = jest.fn((data) => {
return data;
});
reporter = jest.fn(() => {
return Promise.resolve({ id: 1 });
});
jest
.spyOn(global.console, 'log')
.mockImplementation((args) => Promise.resolve(args));
client = new Notifier({
projectId: 1,
projectKey: 'abc',
processor,
reporter,
remoteConfig: false,
});
});
describe('location', () => {
beforeEach(() => {
let locations = ['', 'http://hello/world', 'foo', new Location('/')];
for (let loc of locations) {
try {
window.history.pushState(null, '', loc);
} catch (_) {
// ignore
}
}
client.notify(new Error('test'));
});
it('records browser history', () => {
expect(reporter.mock.calls.length).toBe(1);
let notice = reporter.mock.calls[0][0];
let history = notice.context.history;
let state = history[history.length - 3];
delete state.date;
expect(state).toStrictEqual({
type: 'location',
from: '/',
to: '/world',
});
state = history[history.length - 2];
delete state.date;
expect(state).toStrictEqual({
type: 'location',
from: '/world',
to: '/foo',
});
state = history[history.length - 1];
delete state.date;
expect(state).toStrictEqual({
type: 'location',
from: '/foo',
to: '/',
});
});
});
describe('XHR', () => {
// TODO: use a mock instead of actually sending http requests
beforeEach(() => {
let promise = new Promise((resolve, reject) => {
var req = new XMLHttpRequest();
req.open('GET', 'https://httpbin.org/get');
req.onreadystatechange = () => {
if (req.readyState != 4) return;
if (req.status == 200) {
resolve(req.response);
} else {
reject();
}
};
req.send();
});
promise.then(() => {
client.notify(new Error('test'));
});
return promise;
});
it('records request', () => {
expect(reporter.mock.calls.length).toBe(1);
let notice = reporter.mock.calls[0][0];
let history = notice.context.history;
let state = history[history.length - 1];
expect(state.type).toBe('xhr');
expect(state.method).toBe('GET');
expect(state.url).toBe('https://httpbin.org/get');
expect(state.statusCode).toBe(200);
expect(state.duration).toEqual(expect.any(Number));
});
});
describe('fetch', () => {
// TODO: use a mock instead of actually sending http requests
describe('simple fetch', () => {
beforeEach(() => {
let promise = window.fetch('https://httpbin.org/get');
promise.then(() => {
client.notify(new Error('test'));
});
return promise;
});
it('records request', () => {
expect(reporter.mock.calls.length).toBe(1);
let notice = reporter.mock.calls[0][0];
let history = notice.context.history;
let state = history[history.length - 1];
expect(state.type).toBe('xhr');
expect(state.method).toBe('GET');
expect(state.url).toBe('https://httpbin.org/get');
expect(state.statusCode).toBe(200);
expect(state.duration).toEqual(expect.any(Number));
});
});
describe('fetch with options', () => {
beforeEach(() => {
let promise = window.fetch('https://httpbin.org/post', {
method: 'POST',
});
promise.then(() => {
client.notify(new Error('test'));
});
return promise;
});
it('records request', () => {
expect(reporter.mock.calls.length).toBe(1);
let notice = reporter.mock.calls[0][0];
let history = notice.context.history;
let state = history[history.length - 1];
expect(state.type).toBe('xhr');
expect(state.method).toBe('POST');
expect(state.url).toBe('https://httpbin.org/post');
expect(state.statusCode).toBe(200);
expect(state.duration).toEqual(expect.any(Number));
});
});
describe('fetch with Request object', () => {
beforeEach(() => {
const req = new Request('https://httpbin.org/post', {
method: 'POST',
body: '{"foo": "bar"}',
});
let promise = window.fetch(req);
promise.then(() => {
client.notify(new Error('test'));
});
return promise;
});
it('records request', () => {
expect(reporter.mock.calls.length).toBe(1);
let notice = reporter.mock.calls[0][0];
let history = notice.context.history;
let state = history[history.length - 1];
expect(state.type).toBe('xhr');
expect(state.method).toBe('POST');
expect(state.url).toBe('https://httpbin.org/post');
expect(state.statusCode).toBe(200);
expect(state.duration).toEqual(expect.any(Number));
});
});
});
describe('console', () => {
beforeEach(() => {
for (let i = 0; i < 25; i++) {
// tslint:disable-next-line:no-console
console.log(i);
}
client.notify(new Error('test'));
});
it('records log message', () => {
expect(reporter.mock.calls.length).toBe(1);
let notice = reporter.mock.calls[0][0];
let history = notice.context.history;
expect(history).toHaveLength(20);
for (let i in history) {
if (!history.hasOwnProperty(i)) {
continue;
}
let state = history[i];
expect(state.type).toBe('log');
expect(state.severity).toBe('log');
expect(state.arguments).toStrictEqual([+i + 5]);
expect(state.date).not.toBeNull();
}
});
});
});
================================================
FILE: packages/browser/tests/jsonify_notice.test.js
================================================
import { jsonifyNotice } from '../src/jsonify_notice';
describe('jsonify_notice', () => {
const maxLength = 30000;
describe('when called with notice', () => {
let notice = {
params: { arguments: [] },
environment: { env1: 'value1' },
session: { session1: 'value1' },
};
let json;
beforeEach(() => {
json = jsonifyNotice(notice);
});
it('produces valid JSON', () => {
expect(JSON.parse(json)).toStrictEqual(notice);
});
});
describe('when called with huge notice', () => {
let json;
beforeEach(() => {
let notice = {
params: { arr: [] },
};
for (let i = 0; i < 100; i++) {
notice.params.arr.push(Array(100).join('x'));
}
json = jsonifyNotice(notice, { maxLength });
});
it('limits json size', () => {
expect(json.length).toBeLessThan(maxLength);
});
});
describe('when called with one huge string', () => {
let json;
beforeEach(() => {
let notice = {
params: { str: Array(100000).join('x') },
};
json = jsonifyNotice(notice, { maxLength });
});
it('limits json size', () => {
expect(json.length).toBeLessThan(maxLength);
});
});
describe('when called with huge error message', () => {
let json;
beforeEach(() => {
let notice = {
errors: [
{
type: Array(100000).join('x'),
message: Array(100000).join('x'),
},
],
};
json = jsonifyNotice(notice, { maxLength });
});
it('limits json size', () => {
expect(json.length).toBeLessThan(maxLength);
});
});
describe('when called with huger array', () => {
let json;
beforeEach(() => {
let notice = {
params: { param1: Array(100000) },
};
json = jsonifyNotice(notice, { maxLength });
});
it('limits json size', () => {
expect(json.length).toBeLessThan(maxLength);
});
});
describe('when called with a blocklisted key', () => {
const notice = {
params: { name: 'I will be filtered' },
session: { session1: 'value1' },
context: { notifier: { name: 'airbrake-js' } },
};
let json;
beforeEach(() => {
json = jsonifyNotice(notice, { keysBlocklist: ['name'] });
});
it('filters out blocklisted keys', () => {
expect(JSON.parse(json)).toStrictEqual({
params: { name: '[Filtered]' },
session: { session1: 'value1' },
context: { notifier: { name: 'airbrake-js' } },
});
});
});
describe('keysAllowlist', () => {
describe('when the allowlist key is a string', () => {
const notice = {
params: { name: 'I am allowlisted', email: 'I will be filtered' },
session: { session1: 'I will be filtered, too' },
context: { notifier: { name: 'I am allowlisted' } },
};
let json;
beforeEach(() => {
json = jsonifyNotice(notice, { keysAllowlist: ['name'] });
});
it('filters out everything but allowlisted keys', () => {
expect(JSON.parse(json)).toStrictEqual({
params: { name: 'I am allowlisted', email: '[Filtered]' },
session: { session1: '[Filtered]' },
context: { notifier: { name: 'I am allowlisted' } },
});
});
});
describe('when the allowlist key is a regexp', () => {
const notice = {
params: { name: 'I am allowlisted', email: 'I will be filtered' },
session: { session1: 'I will be filtered, too' },
context: { notifier: { name: 'I am allowlisted' } },
};
let json;
beforeEach(() => {
json = jsonifyNotice(notice, { keysAllowlist: [/nam/] });
});
it('filters out everything but allowlisted keys', () => {
expect(JSON.parse(json)).toStrictEqual({
params: { name: 'I am allowlisted', email: '[Filtered]' },
session: { session1: '[Filtered]' },
context: { notifier: { name: 'I am allowlisted' } },
});
});
});
});
describe('when called both with a blocklist and an allowlist', () => {
const notice = {
params: { name: 'Name' },
session: { session1: 'value1' },
context: { notifier: { name: 'airbrake-js' } },
};
let json;
beforeEach(() => {
json = jsonifyNotice(notice, {
keysBlocklist: ['name'],
keysAllowlist: ['name'],
});
});
it('ignores the blocklist and uses the allowlist', () => {
expect(JSON.parse(json)).toStrictEqual({
params: { name: 'Name' },
session: { session1: '[Filtered]' },
context: { notifier: { name: 'airbrake-js' } },
});
});
});
});
================================================
FILE: packages/browser/tests/processor/stacktracejs.test.js
================================================
import { INoticeError } from '../../src/notice';
import { espProcessor } from '../../src/processor/esp';
describe('stacktracejs processor', () => {
let error;
describe('Error', () => {
function throwTestError() {
try {
throw new Error('BOOM');
} catch (err) {
error = espProcessor(err);
}
}
beforeEach(() => {
throwTestError();
});
it('provides type and message', () => {
expect(error.type).toBe('Error');
expect(error.message).toBe('BOOM');
});
it('provides backtrace', () => {
let backtrace = error.backtrace;
expect(backtrace.length).toBeGreaterThanOrEqual(5);
let frame = backtrace[0];
expect(frame.file).toContain('tests/processor/stacktracejs.test');
expect(frame.function).toBe('throwTestError');
expect(frame.line).toEqual(expect.any(Number));
expect(frame.column).toEqual(expect.any(Number));
});
});
describe('text', () => {
beforeEach(() => {
let err;
err = 'BOOM';
error = espProcessor(err);
});
it('uses text as error message', () => {
expect(error.type).toBe('');
expect(error.message).toBe('BOOM');
});
it('provides backtrace', () => {
let backtrace = error.backtrace;
expect(backtrace.length).toBeGreaterThanOrEqual(4);
});
});
});
================================================
FILE: packages/browser/tests/remote_settings.test.js
================================================
import { SettingsData } from '../src/remote_settings';
describe('SettingsData', () => {
describe('merge', () => {
it('merges JSON with a SettingsData', () => {
const disabledApm = { settings: [{ name: 'apm', enabled: false }] };
const enabledApm = { settings: [{ name: 'apm', enabled: true }] };
const s = new SettingsData(1, disabledApm);
s.merge(enabledApm);
expect(s._data).toMatchObject(enabledApm);
});
});
describe('configRoute', () => {
describe('when config_route in JSON is null', () => {
it('returns the default route', () => {
const s = new SettingsData(1, { config_route: null });
expect(s.configRoute('http://example.com/')).toMatch(
'http://example.com/2020-06-18/config/1/config.json'
);
});
});
describe('when config_route in JSON is undefined', () => {
it('returns the default route', () => {
const s = new SettingsData(1, { config_route: undefined });
expect(s.configRoute('http://example.com/')).toMatch(
'http://example.com/2020-06-18/config/1/config.json'
);
});
});
describe('when config_route in JSON is an empty string', () => {
it('returns the default route', () => {
const s = new SettingsData(1, { config_route: '' });
expect(s.configRoute('http://example.com/')).toMatch(
'http://example.com/2020-06-18/config/1/config.json'
);
});
});
describe('when config_route in JSON is specified', () => {
it('returns the specified route', () => {
const s = new SettingsData(1, { config_route: 'ROUTE/cfg.json' });
expect(s.configRoute('http://example.com/')).toMatch(
'http://example.com/ROUTE/cfg.json'
);
});
});
describe('when the given host does not contain an ending slash', () => {
it('returns the specified route', () => {
const s = new SettingsData(1, { config_route: 'ROUTE/cfg.json' });
expect(s.configRoute('http://example.com')).toMatch(
'http://example.com/ROUTE/cfg.json'
);
});
});
});
describe('errorNotifications', () => {
describe('when the "errors" setting exists', () => {
describe('and when it is enabled', () => {
it('returns true', () => {
const s = new SettingsData(1, {
settings: [{ name: 'errors', enabled: true }],
});
expect(s.errorNotifications()).toBe(true);
});
});
describe('and when it is disabled', () => {
it('returns false', () => {
const s = new SettingsData(1, {
settings: [{ name: 'errors', enabled: false }],
});
expect(s.errorNotifications()).toBe(false);
});
});
});
describe('when the "errors" setting DOES NOT exist', () => {
it('returns true', () => {
const s = new SettingsData(1, {});
expect(s.errorNotifications()).toBe(true);
});
});
});
describe('performanceStats', () => {
describe('when the "apm" setting exists', () => {
describe('and when it is enabled', () => {
it('returns true', () => {
const s = new SettingsData(1, {
settings: [{ name: 'apm', enabled: true }],
});
expect(s.performanceStats()).toBe(true);
});
});
describe('and when it is disabled', () => {
it('returns false', () => {
const s = new SettingsData(1, {
settings: [{ name: 'apm', enabled: false }],
});
expect(s.performanceStats()).toBe(false);
});
});
});
describe('when the "errors" setting DOES NOT exist', () => {
it('returns true', () => {
const s = new SettingsData(1, {});
expect(s.performanceStats()).toBe(true);
});
});
});
describe('errorHost', () => {
describe('when the "errors" setting exists', () => {
describe('and when it has an endpoint specified', () => {
it('returns the endpoint', () => {
const s = new SettingsData(1, {
settings: [{ name: 'errors', endpoint: 'http://example.com' }],
});
expect(s.errorHost()).toMatch('http://example.com');
});
});
describe('and when it has null endpoint', () => {
it('returns null', () => {
const s = new SettingsData(1, {
settings: [{ name: 'errors', endpoint: null }],
});
expect(s.errorHost()).toBe(null);
});
});
});
describe('when the "errors" setting DOES NOT exist', () => {
it('returns null', () => {
const s = new SettingsData(1, {});
expect(s.errorHost()).toBe(null);
});
});
});
describe('apmHost', () => {
describe('when the "apm" setting exists', () => {
describe('and when it has an endpoint specified', () => {
it('returns the endpoint', () => {
const s = new SettingsData(1, {
settings: [{ name: 'apm', endpoint: 'http://example.com' }],
});
expect(s.apmHost()).toMatch('http://example.com');
});
});
describe('and when it has null endpoint', () => {
it('returns null', () => {
const s = new SettingsData(1, {
settings: [{ name: 'apm', endpoint: null }],
});
expect(s.apmHost()).toBe(null);
});
});
});
describe('when the "apm" setting DOES NOT exist', () => {
it('returns null', () => {
const s = new SettingsData(1, {});
expect(s.apmHost()).toBe(null);
});
});
});
});
================================================
FILE: packages/browser/tests/truncate.test.js
================================================
import { truncate } from '../src/jsonify_notice';
describe('truncate', () => {
it('works', () => {
/* tslint:disable */
let tests = [
[undefined],
[null],
[true],
[false],
[new Boolean(true)],
[1],
[3.14],
[new Number(1)],
[Infinity],
[NaN],
[Math.LN2],
['hello'],
[new String('hello'), 'hello'],
[['foo', 'bar']],
[{ foo: 'bar' }],
[new Date()],
[/a/],
[new RegExp('a')],
[new Error('hello'), 'Error: hello'],
];
/* tslint:enable */
for (let test of tests) {
let wanted = test.length >= 2 ? test[1] : test[0];
if (isNaN(wanted)) {
continue;
}
expect(truncate(test[0])).toBe(wanted);
}
});
it('omits functions in object', () => {
/* tslint:disable */
let obj = {
foo: 'bar',
fn1: Math.sin,
fn2: () => null,
fn3: new Function('x', 'y', 'return x * y'),
};
/* tslint:enable */
expect(truncate(obj)).toStrictEqual({ foo: 'bar' });
});
it('sets object type', () => {
let e = new Event('load');
let got = truncate(e);
expect(got.__type).toBe('Event');
});
describe('when called with object with circular references', () => {
let obj = { foo: 'bar' };
obj.circularRef = obj;
obj.circularList = [obj, obj];
let truncated;
beforeEach(() => {
truncated = truncate(obj);
});
it('produces object with resolved circular references', () => {
expect(truncated).toStrictEqual({
foo: 'bar',
circularRef: '[Circular ~]',
circularList: ['[Circular ~]', '[Circular ~]'],
});
});
});
describe('when called with object with complex circular references', () => {
let a = { x: 1 };
a.a = a;
let b = { x: 2 };
b.a = a;
let c = { a, b };
let obj = { list: [a, b, c] };
obj.obj = obj;
let truncated;
beforeEach(() => {
truncated = truncate(obj);
});
it('produces object with resolved circular references', () => {
expect(truncated).toStrictEqual({
list: [
{
x: 1,
a: '[Circular ~.list.0]',
},
{
x: 2,
a: '[Circular ~.list.0]',
},
{
a: '[Circular ~.list.0]',
b: '[Circular ~.list.1]',
},
],
obj: '[Circular ~]',
});
});
});
describe('when called with deeply nested objects', () => {
let obj = {};
let tmp = obj;
for (let i = 0; i < 100; i++) {
tmp.value = i;
tmp.obj = {};
tmp = tmp.obj;
}
let truncated;
beforeEach(() => {
truncated = truncate(obj, { level: 1 });
});
it('produces truncated object', () => {
expect(truncated).toStrictEqual({
value: 0,
obj: {
value: 1,
obj: {
value: 2,
obj: {
value: 3,
obj: '[Truncated Object]',
},
},
},
});
});
});
describe('when called with object created with Object.create(null)', () => {
it('works', () => {
let obj = Object.create(null);
obj.foo = 'bar';
expect(truncate(obj)).toStrictEqual({ foo: 'bar' });
});
});
describe('keysBlocklist', () => {
it('filters blocklisted keys', () => {
let obj = {
params: {
password: '123',
sub: {
secret: '123',
},
},
};
let keysBlocklist = [/password/, /secret/];
let truncated = truncate(obj, { keysBlocklist });
expect(truncated).toStrictEqual({
params: {
password: '[Filtered]',
sub: { secret: '[Filtered]' },
},
});
});
});
});
================================================
FILE: packages/browser/tsconfig.cjs.json
================================================
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}
================================================
FILE: packages/browser/tsconfig.esm.json
================================================
{
"extends": "../../tsconfig.esm.json",
"compilerOptions": {
"outDir": "esm"
},
"include": ["src"]
}
================================================
FILE: packages/browser/tsconfig.json
================================================
{
"extends": "./tsconfig.cjs.json",
"compilerOptions": {
"rootDir": ".",
},
"include": ["src", "test"]
}
================================================
FILE: packages/browser/tsconfig.umd.json
================================================
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": false,
"declarationMap": false,
"module": "ES6",
}
}
================================================
FILE: packages/browser/tslint.json
================================================
{
"extends": ["../../tslint.json"]
}
================================================
FILE: packages/node/LICENSE
================================================
MIT License
Copyright (c) 2020 Airbrake Technologies, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: packages/node/README.md
================================================
# Official Airbrake Notifier for Node.js
[](https://github.com/airbrake/airbrake-js/actions?query=branch%3Amaster)
[](https://www.npmjs.com/package/@airbrake/node)
[](https://www.npmjs.com/package/@airbrake/node)
[](https://www.npmjs.com/package/@airbrake/node)
The official Airbrake notifier for capturing JavaScript errors in Node.js and
reporting them to [Airbrake](http://airbrake.io). If you're looking for
browser support, there is a
[separate package](https://github.com/airbrake/airbrake-js/tree/master/packages/browser).
## Installation
Using yarn:
```sh
yarn add @airbrake/node
```
Using npm:
```sh
npm install @airbrake/node
```
## Basic Usage
First, initialize the notifier with the project ID and project key taken from
[Airbrake](https://airbrake.io). To find your `project_id` and `project_key`
navigate to your project's _Settings_ and copy the values from the right
sidebar:
![][project-idkey]
```js
const { Notifier } = require('@airbrake/node');
const airbrake = new Notifier({
projectId: 1,
projectKey: 'REPLACE_ME',
environment: 'production',
});
```
Then, you can send a textual message to Airbrake:
```js
let promise = airbrake.notify(`user id=${user_id} not found`);
promise.then((notice) => {
if (notice.id) {
console.log('notice id', notice.id);
} else {
console.log('notify failed', notice.error);
}
});
```
or report errors directly:
```js
try {
throw new Error('Hello from Airbrake!');
} catch (err) {
airbrake.notify(err);
}
```
Alternatively, you can wrap any code which may throw errors using the `wrap`
method:
```js
let startApp = () => {
throw new Error('Hello from Airbrake!');
};
startApp = airbrake.wrap(startApp);
// Any exceptions thrown in startApp will be reported to Airbrake.
startApp();
```
or use the `call` shortcut:
```js
let startApp = () => {
throw new Error('Hello from Airbrake!');
};
airbrake.call(startApp);
```
## Example configurations
- [Express](examples/express)
- [Node.js](examples/nodejs)
## Advanced Usage
### Notice Annotations
It's possible to annotate error notices with all sorts of useful information at
the time they're captured by supplying it in the object being reported.
```js
try {
startApp();
} catch (err) {
airbrake.notify({
error: err,
context: { component: 'bootstrap' },
environment: { env1: 'value' },
params: { param1: 'value' },
session: { session1: 'value' },
});
}
```
### Severity
[Severity](https://airbrake.io/docs/airbrake-faq/what-is-severity/) allows
categorizing how severe an error is. By default, it's set to `error`. To
redefine severity, simply overwrite `context/severity` of a notice object:
```js
airbrake.notify({
error: err,
context: { severity: 'warning' },
});
```
### Filtering errors
There may be some errors thrown in your application that you're not interested
in sending to Airbrake, such as errors thrown by 3rd-party libraries.
The Airbrake notifier makes it simple to ignore this chaff while still
processing legitimate errors. Add filters to the notifier by providing filter
functions to `addFilter`.
`addFilter` accepts the entire
[error notice](https://airbrake.io/docs/api/#create-notice-v3) to be sent to
Airbrake and provides access to the `context`, `environment`, `params`,
and `session` properties. It also includes the single-element `errors` array
with its `backtrace` property and associated backtrace lines.
The return value of the filter function determines whether or not the error
notice will be submitted.
- If `null` is returned, the notice is ignored.
- Otherwise, the returned notice will be submitted.
An error notice must pass all provided filters to be submitted.
In the following example all errors triggered by admins will be ignored:
```js
airbrake.addFilter((notice) => {
if (notice.params.admin) {
// Ignore errors from admin sessions.
return null;
}
return notice;
});
```
Filters can be also used to modify notice payload, e.g. to set the environment
and application version:
```js
airbrake.addFilter((notice) => {
notice.context.environment = 'production';
notice.context.version = '1.2.3';
return notice;
});
```
### Filtering keys
With the `keysBlocklist` option, you can specify a list of keys containing
sensitive information that must be filtered out:
```js
const airbrake = new Notifier({
// ...
keysBlocklist: [
'password', // exact match
/secret/, // regexp match
],
});
```
### Node.js request and proxy
To use the [request](https://github.com/request/request) HTTP client, pass
the `request` option which accepts a request wrapper:
```js
const airbrake = new Notifier({
// ...
request: request.defaults({ proxy: 'http://localproxy.com' }),
});
```
### Instrumentation
`@airbrake/node` attempts to automatically instrument various performance
metrics. You can disable that behavior using the `performanceStats` option:
```js
const airbrake = new Notifier({
// ...
performanceStats: false,
});
```
### Filtering performance data
`addPerformanceFilter` allows for filtering performance data. Return `null` in
the filter to prevent that metric from being reported to Airbrake.
```js
airbrake.addPerformanceFilter((metric) => {
if (metric.route === '/foo') {
// Requests to '/foo' will not be reported
return null;
}
return metric;
});
```
[project-idkey]: https://s3.amazonaws.com/airbrake-github-assets/airbrake-js/project-id-key.png
================================================
FILE: packages/node/babel.config.js
================================================
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
};
================================================
FILE: packages/node/examples/express/README.md
================================================
# Using Airbrake with Express.js
This example Node.js application uses Express.js and sets up Airbrake to report
errors and performance data. To adapt this example to your app, follow these
steps:
#### 1. Install the package
```shell
npm install @airbrake/node
```
#### 2. Include @airbrake/node and the Express.js instrumentation in your app
Include the required Airbrake libraries in your `app.js`
```js
const Airbrake = require('@airbrake/node');
const airbrakeExpress = require('@airbrake/node/dist/instrumentation/express');
```
#### 3. Configure Airbrake with your project's credentials
```js
const airbrake = new Airbrake.Notifier({
projectId: process.env.AIRBRAKE_PROJECT_ID,
projectKey: process.env.AIRBRAKE_PROJECT_KEY,
});
```
#### 4. Add the Airbrake Express middleware
This middleware should be added before any routes are defined.
```js
app.use(airbrakeExpress.makeMiddleware(airbrake));
```
#### 5. Add the Airbrake Express error handler
The error handler middleware should be defined last. For more info on how this
works, see the official
[Express error handling doc](http://expressjs.com/en/guide/error-handling.html).
```js
app.use(airbrakeExpress.makeErrorHandler(airbrake));
```
#### 6. Run your app
The last step is to run your app. To test that you've configured Airbrake
correctly, you can throw an error inside any of your routes:
```js
app.get('/hello/:name', function hello(_req, _res) {
throw new Error('Hello from Airbrake!');
});
```
Any unhandled errors that are thrown will now be reported to Airbrake. See the
[basic usage](https://github.com/airbrake/airbrake-js/tree/master/packages/node#basic-usage)
to learn how to manually send errors to Airbrake.
**Note:** to see this all in action, take a look at our
[example `app.js` file](https://github.com/airbrake/airbrake-js/blob/master/packages/node/examples/express/app.js)
and to run the example, follow the next steps.
# Running the example app
If you want to run this example application locally, follow these steps:
#### 1. Clone the airbrake-js repo:
```shell
git clone git@github.com:airbrake/airbrake-js.git
```
#### 2. Navigate to this directory:
```
cd airbrake-js/packages/node/examples/express
```
#### 3. Run the following commands while providing your `project ID` and `project API key`
```shell
npm install
AIRBRAKE_PROJECT_ID=your-id AIRBRAKE_PROJECT_KEY=your-key node app.js
firefox localhost:3000
```
================================================
FILE: packages/node/examples/express/app.js
================================================
const express = require('express');
const pg = require('pg');
const Airbrake = require('@airbrake/node');
const airbrakeExpress = require('@airbrake/node/dist/instrumentation/express');
async function main() {
const airbrake = new Airbrake.Notifier({
projectId: process.env.AIRBRAKE_PROJECT_ID,
projectKey: process.env.AIRBRAKE_PROJECT_KEY,
});
const client = new pg.Client();
await client.connect();
const app = express();
// This middleware should be added before any routes are defined.
app.use(airbrakeExpress.makeMiddleware(airbrake));
app.get('/', async function home(req, res) {
const result = await client.query('SELECT $1::text as message', [
'Hello world!',
]);
console.log(result.rows[0].message);
res.send('Hello World!');
});
app.get('/hello/:name', function hello(_req, _res) {
throw new Error('Hello from Airbrake!');
});
// Error handler middleware should be the last one.
// See http://expressjs.com/en/guide/error-handling.html
app.use(airbrakeExpress.makeErrorHandler(airbrake));
app.listen(3000, function() {
console.log('Example app listening on port 3000!');
});
}
main();
================================================
FILE: packages/node/examples/express/package.json
================================================
{
"name": "airbrake-example",
"dependencies": {
"@airbrake/node": "^2.1.9",
"express": "^4.17.1",
"pg": "^8.0.0"
}
}
================================================
FILE: packages/node/examples/nodejs/README.md
================================================
# Using Airbrake with Node.js
#### 1. Install the package
```shell
npm install @airbrake/node
```
#### 2. Include @airbrake/node in your app
Include the required Airbrake libraries in your `app.js`
```js
const Airbrake = require('@airbrake/node');
```
#### 3. Configure Airbrake with your project's credentials
```js
const airbrake = new Airbrake.Notifier({
projectId: process.env.AIRBRAKE_PROJECT_ID,
projectKey: process.env.AIRBRAKE_PROJECT_KEY,
});
```
#### 4. Run your app
The last step is to run your app. To test that you've configured Airbrake
correctly, you can throw an error inside any of your routes:
```js
const hostname = '127.0.0.1';
const port = 3000;
const server = http.createServer((_req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Hello World');
throw new Error('I am an uncaught exception');
});
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
```
Any unhandled errors that are thrown will now be reported to Airbrake. See the
[basic usage](https://github.com/airbrake/airbrake-js/tree/master/packages/node#basic-usage)
to learn how to manually send errors to Airbrake.
**Note:** to see this all in action, take a look at our
[example `app.js` file](https://github.com/airbrake/airbrake-js/blob/master/packages/node/examples/nodejs/app.js)
and to run the example, follow the next steps.
# Running the example app
If you want to run this example application locally, follow these steps:
#### 1. Clone the airbrake-js repo:
```shell
git clone git@github.com:airbrake/airbrake-js.git
```
#### 2. Navigate to this directory:
```
cd airbrake-js/packages/node/examples/nodejs
```
#### 3. Run the following commands while providing your `project ID` and `project API key`
```shell
npm install
AIRBRAKE_PROJECT_ID=your-id AIRBRAKE_PROJECT_KEY=your-key node app.js
firefox localhost:3000
```
================================================
FILE: packages/node/examples/nodejs/app.js
================================================
const http = require('http');
const Airbrake = require('@airbrake/node');
new Airbrake.Notifier({
projectId: process.env.AIRBRAKE_PROJECT_ID,
projectKey: process.env.AIRBRAKE_PROJECT_KEY,
});
const hostname = '127.0.0.1';
const port = 3000;
const server = http.createServer((_req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Hello World');
throw new Error('I am an uncaught exception');
});
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
================================================
FILE: packages/node/examples/nodejs/package.json
================================================
{
"name": "airbrake-example",
"dependencies": {
"@airbrake/node": "^2.1.9"
}
}
================================================
FILE: packages/node/jest.config.js
================================================
module.exports = {
transform: {
'^.+\\.jsx?$': 'babel-jest',
'^.+\\.tsx?$': 'ts-jest',
},
testEnvironment: 'node',
moduleNameMapper: {
'^@airbrake/(.*)$': '/../$1/src',
},
roots: ['tests'],
clearMocks: true,
};
================================================
FILE: packages/node/package.json
================================================
{
"name": "@airbrake/node",
"version": "2.1.9",
"description": "Official Airbrake notifier for Node.js",
"author": "Airbrake",
"license": "MIT",
"repository": {
"type": "git",
"url": "git://github.com/airbrake/airbrake-js.git",
"directory": "packages/node"
},
"homepage": "https://github.com/airbrake/airbrake-js/tree/master/packages/node",
"keywords": [
"exception",
"error",
"airbrake",
"notifier"
],
"engines": {
"node": ">=10"
},
"dependencies": {
"@airbrake/browser": "^2.1.9",
"cross-fetch": "^3.1.5",
"error-stack-parser": "^2.0.4",
"tdigest": "^0.1.1"
},
"devDependencies": {
"@babel/core": "^7.9.0",
"@babel/preset-env": "^7.9.0",
"babel-jest": "^29.3.1",
"jest": "^27.3.1",
"prettier": "^2.0.2",
"ts-jest": "^27.1.0",
"tslint": "^6.1.0",
"tslint-config-prettier": "^1.18.0",
"tslint-plugin-prettier": "^2.3.0",
"typescript": "^4.0.2"
},
"main": "dist/index.js",
"module": "esm/index.js",
"files": [
"dist/",
"esm/",
"README.md",
"LICENSE"
],
"scripts": {
"build": "yarn build:cjs && yarn build:esm",
"build:watch": "concurrently 'yarn build:cjs:watch' 'yarn build:esm:watch'",
"build:cjs": "tsc -p tsconfig.cjs.json",
"build:cjs:watch": "tsc -p tsconfig.cjs.json -w --preserveWatchOutput",
"build:esm": "tsc -p tsconfig.esm.json",
"build:esm:watch": "tsc -p tsconfig.esm.json -w --preserveWatchOutput",
"clean": "rm -rf dist esm",
"lint": "tslint -p .",
"test": "jest"
}
}
================================================
FILE: packages/node/src/filter/node.ts
================================================
import { INotice } from '@airbrake/browser';
import { NOTIFIER_NAME, NOTIFIER_VERSION, NOTIFIER_URL } from '../version';
const os = require('os');
export function nodeFilter(notice: INotice): INotice {
if (notice.context.notifier) {
notice.context.notifier.name = NOTIFIER_NAME;
notice.context.notifier.version = NOTIFIER_VERSION;
notice.context.notifier.url = NOTIFIER_URL;
}
notice.context.os = `${os.type()}/${os.release()}`;
notice.context.architecture = os.arch();
notice.context.hostname = os.hostname();
notice.params.os = {
homedir: os.homedir(),
uptime: os.uptime(),
freemem: os.freemem(),
totalmem: os.totalmem(),
loadavg: os.loadavg(),
};
notice.context.platform = process.platform;
if (!notice.context.rootDirectory) {
notice.context.rootDirectory = process.cwd();
}
notice.params.process = {
pid: process.pid,
cwd: process.cwd(),
execPath: process.execPath,
argv: process.argv,
};
['uptime', 'cpuUsage', 'memoryUsage'].map((name) => {
if (process[name]) {
notice.params.process[name] = process[name]();
}
});
return notice;
}
================================================
FILE: packages/node/src/index.ts
================================================
export { Notifier } from './notifier';
================================================
FILE: packages/node/src/instrumentation/debug.ts
================================================
import { Notifier } from '../notifier';
export function patch(createDebug, airbrake: Notifier): void {
const oldInit = createDebug.init;
createDebug.init = function (debug) {
oldInit.apply(this, arguments);
const oldLog = debug.log || createDebug.log;
debug.log = function abCreateDebug() {
airbrake.scope().pushHistory({
type: 'log',
arguments,
});
return oldLog.apply(this, arguments);
};
};
}
================================================
FILE: packages/node/src/instrumentation/express.ts
================================================
import { Notifier } from '../notifier';
export function makeMiddleware(airbrake: Notifier) {
return function airbrakeMiddleware(req, res, next): void {
const route = req.route?.path?.toString() ?? 'UNKNOWN';
const metric = airbrake.routes.start(req.method, route);
if (!metric.isRecording()) {
next();
return;
}
const origEnd = res.end;
res.end = function abEnd() {
metric.route = req.route?.path?.toString() ?? 'UNKNOWN';
metric.statusCode = res.statusCode;
metric.contentType = res.get('Content-Type');
airbrake.routes.notify(metric);
return origEnd.apply(this, arguments);
};
next();
};
}
export function makeErrorHandler(airbrake: Notifier) {
return function airbrakeErrorHandler(err: Error, req, _res, next): void {
const url = req.protocol + '://' + req.headers.host + req.originalUrl;
const notice: any = {
error: err,
context: {
userAddr: req.ip,
userAgent: req.headers['user-agent'],
url,
httpMethod: req.method,
component: 'express',
},
};
if (req.route) {
if (req.route.path) {
notice.context.route = req.route.path.toString();
}
if (req.route.stack && req.route.stack.length) {
notice.context.action = req.route.stack[0].name;
}
}
const referer = req.headers.referer;
if (referer) {
notice.context.referer = referer;
}
airbrake.notify(notice);
next(err);
};
}
================================================
FILE: packages/node/src/instrumentation/http.ts
================================================
import { Notifier } from '../notifier';
const SPAN_NAME = 'http';
export function patch(http, airbrake: Notifier): void {
if (http.request) {
http.request = wrapRequest(http.request, airbrake);
}
if (http.get) {
http.get = wrapRequest(http.get, airbrake);
}
}
export function wrapRequest(origFn, airbrake: Notifier) {
return function abRequest() {
const metric = airbrake.scope().routeMetric();
metric.startSpan(SPAN_NAME);
const req = origFn.apply(this, arguments);
if (!metric.isRecording()) {
return req;
}
const origEmit = req.emit;
req.emit = function (type, _res) {
if (type === 'response') {
metric.endSpan(SPAN_NAME);
}
return origEmit.apply(this, arguments);
};
return req;
};
}
================================================
FILE: packages/node/src/instrumentation/https.ts
================================================
import { Notifier } from '../notifier';
import { wrapRequest } from './http';
export function patch(https, airbrake: Notifier): void {
if (https.request) {
https.request = wrapRequest(https.request, airbrake);
}
if (https.get) {
https.get = wrapRequest(https.get, airbrake);
}
}
================================================
FILE: packages/node/src/instrumentation/mysql.ts
================================================
import { QueryInfo } from '@airbrake/browser';
import { Notifier } from '../notifier';
const SPAN_NAME = 'sql';
export function patch(mysql, airbrake: Notifier): void {
mysql.createPool = wrapCreatePool(mysql.createPool, airbrake);
const origCreatePoolCluster = mysql.createPoolCluster;
mysql.createPoolCluster = function abCreatePoolCluster() {
const cluster = origCreatePoolCluster.apply(this, arguments);
cluster.of = wrapCreatePool(cluster.of, airbrake);
return cluster;
};
const origCreateConnection = mysql.createConnection;
mysql.createConnection = function abCreateConnection() {
const conn = origCreateConnection.apply(this, arguments);
wrapConnection(conn, airbrake);
return conn;
};
}
function wrapCreatePool(origFn, airbrake: Notifier) {
return function abCreatePool() {
const pool = origFn.apply(this, arguments);
pool.getConnection = wrapGetConnection(pool.getConnection, airbrake);
return pool;
};
}
function wrapGetConnection(origFn, airbrake: Notifier) {
return function abGetConnection() {
const cb = arguments[0];
if (typeof cb === 'function') {
arguments[0] = function abCallback(_err, conn) {
if (conn) {
wrapConnection(conn, airbrake);
}
return cb.apply(this, arguments);
};
}
return origFn.apply(this, arguments);
};
}
function wrapConnection(conn, airbrake: Notifier): void {
const origQuery = conn.query;
conn.query = function abQuery(sql, values, cb) {
let foundCallback = false;
function wrapCallback(callback) {
foundCallback = true;
return function abCallback() {
endSpan();
return callback.apply(this, arguments);
};
}
const metric = airbrake.scope().routeMetric();
if (!metric.isRecording()) {
return origQuery.apply(this, arguments);
}
metric.startSpan(SPAN_NAME);
let qinfo: QueryInfo;
const endSpan = () => {
metric.endSpan(SPAN_NAME);
if (qinfo) {
airbrake.queries.notify(qinfo);
}
};
let query: string;
switch (typeof sql) {
case 'string':
query = sql;
break;
case 'function':
arguments[0] = wrapCallback(sql);
break;
case 'object':
if (typeof sql._callback === 'function') {
sql._callback = wrapCallback(sql._callback);
}
query = sql.sql;
break;
}
if (query) {
qinfo = airbrake.queries.start(query);
}
if (typeof values === 'function') {
arguments[1] = wrapCallback(values);
} else if (typeof cb === 'function') {
arguments[2] = wrapCallback(cb);
}
const res = origQuery.apply(this, arguments);
if (!foundCallback && res && res.emit) {
const origEmit = res.emit;
res.emit = function abEmit(evt) {
switch (evt) {
case 'end':
case 'error':
endSpan();
break;
}
return origEmit.apply(this, arguments);
};
}
return res;
};
}
================================================
FILE: packages/node/src/instrumentation/mysql2.ts
================================================
import { QueryInfo } from '@airbrake/browser';
import { Notifier } from '../notifier';
const SPAN_NAME = 'sql';
export function patch(mysql2, airbrake: Notifier): void {
const proto = mysql2.Connection.prototype;
proto.query = wrapQuery(proto.query, airbrake);
proto.execute = wrapQuery(proto.execute, airbrake);
}
function wrapQuery(origQuery, airbrake: Notifier) {
return function abQuery(sql, values, cb) {
const metric = airbrake.scope().routeMetric();
if (!metric.isRecording()) {
return origQuery.apply(this, arguments);
}
metric.startSpan(SPAN_NAME);
let qinfo: QueryInfo;
const endSpan = () => {
metric.endSpan(SPAN_NAME);
if (qinfo) {
airbrake.queries.notify(qinfo);
}
};
let foundCallback = false;
function wrapCallback(callback) {
foundCallback = true;
return function abCallback() {
endSpan();
return callback.apply(this, arguments);
};
}
let query: string;
switch (typeof sql) {
case 'string':
query = sql;
break;
case 'function':
arguments[0] = wrapCallback(sql);
break;
case 'object':
if (typeof sql.onResult === 'function') {
sql.onResult = wrapCallback(sql.onResult);
}
query = sql.sql;
break;
}
if (query) {
qinfo = airbrake.queries.start(query);
}
if (typeof values === 'function') {
arguments[1] = wrapCallback(values);
} else if (typeof cb === 'function') {
arguments[2] = wrapCallback(cb);
}
const res = origQuery.apply(this, arguments);
if (!foundCallback && res && res.emit) {
const origEmit = res.emit;
res.emit = function abEmit(evt) {
switch (evt) {
case 'end':
case 'error':
case 'close':
endSpan();
break;
}
return origEmit.apply(this, arguments);
};
}
return res;
};
}
================================================
FILE: packages/node/src/instrumentation/pg.ts
================================================
import { QueryInfo } from '@airbrake/browser';
import { Notifier } from '../notifier';
const SPAN_NAME = 'sql';
export function patch(pg, airbrake: Notifier): void {
patchClient(pg.Client, airbrake);
const origGetter = pg.__lookupGetter__('native');
if (origGetter) {
delete pg.native;
pg.__defineGetter__('native', () => {
const native = origGetter();
if (native && native.Client) {
patchClient(native.Client, airbrake);
}
return native;
});
}
}
// tslint:disable-next-line: variable-name
function patchClient(Client, airbrake: Notifier): void {
const origQuery = Client.prototype.query;
Client.prototype.query = function abQuery(sql) {
const metric = airbrake.scope().routeMetric();
if (!metric.isRecording()) {
return origQuery.apply(this, arguments);
}
metric.startSpan(SPAN_NAME);
if (sql && typeof sql.text === 'string') {
sql = sql.text;
}
let qinfo: QueryInfo;
if (typeof sql === 'string') {
qinfo = airbrake.queries.start(sql);
}
let cbIdx = arguments.length - 1;
let cb = arguments[cbIdx];
if (Array.isArray(cb)) {
cbIdx = cb.length - 1;
cb = cb[cbIdx];
}
const endSpan = () => {
metric.endSpan(SPAN_NAME);
if (qinfo) {
airbrake.queries.notify(qinfo);
}
};
if (typeof cb === 'function') {
arguments[cbIdx] = function abCallback() {
endSpan();
return cb.apply(this, arguments);
};
return origQuery.apply(this, arguments);
}
const query = origQuery.apply(this, arguments);
if (typeof query.on === 'function') {
query.on('end', endSpan);
query.on('error', endSpan);
} else if (
typeof query.then === 'function' &&
typeof query.catch === 'function'
) {
query.then(endSpan).catch(endSpan);
}
return query;
};
}
================================================
FILE: packages/node/src/instrumentation/redis.ts
================================================
import { Notifier } from '../notifier';
const SPAN_NAME = 'redis';
export function patch(redis, airbrake: Notifier): void {
const proto = redis.RedisClient.prototype;
const origSendCommand = proto.internal_send_command;
proto.internal_send_command = function ab_internal_send_command(cmd) {
const metric = airbrake.scope().routeMetric();
metric.startSpan(SPAN_NAME);
if (!metric.isRecording()) {
return origSendCommand.apply(this, arguments);
}
if (cmd && cmd.callback) {
const origCb = cmd.callback;
cmd.callback = function abCallback() {
metric.endSpan(SPAN_NAME);
return origCb.apply(this, arguments);
};
}
return origSendCommand.apply(this, arguments);
};
}
================================================
FILE: packages/node/src/notifier.ts
================================================
import { BaseNotifier, INotice, IOptions } from '@airbrake/browser';
import { nodeFilter } from './filter/node';
import { Scope, ScopeManager } from './scope';
export class Notifier extends BaseNotifier {
_inFlight: number;
_scopeManager?: ScopeManager;
_mainScope?: Scope;
constructor(opt: IOptions) {
if (!opt.environment && process.env.NODE_ENV) {
opt.environment = process.env.NODE_ENV;
}
super(opt);
this.addFilter(nodeFilter);
this._inFlight = 0;
process.on('beforeExit', async () => {
await this.flush();
});
process.on('uncaughtException', (err) => {
this.notify(err).then(() => {
if (process.listeners('uncaughtException').length !== 1) {
return;
}
if (console.error) {
console.error('uncaught exception', err);
}
process.exit(1);
});
});
process.on('unhandledRejection', (reason: Error, _p) => {
let msg = reason.message || String(reason);
if (msg.indexOf && msg.indexOf('airbrake: ') === 0) {
return;
}
this.notify(reason).then(() => {
if (process.listeners('unhandledRejection').length !== 1) {
return;
}
if (console.error) {
console.error('unhandled rejection', reason);
}
process.exit(1);
});
});
if (opt.performanceStats) {
this._instrument();
this._scopeManager = new ScopeManager();
}
this._mainScope = new Scope();
}
scope(): Scope {
if (this._scopeManager) {
const scope = this._scopeManager.active();
if (scope) {
return scope;
}
}
return this._mainScope;
}
setActiveScope(scope: Scope) {
this._scopeManager.setActive(scope);
}
notify(err: any): Promise {
this._inFlight++;
return super.notify(err).finally(() => {
this._inFlight--;
});
}
async flush(timeout = 3000): Promise {
if (this._inFlight === 0 || timeout <= 0) {
return Promise.resolve(true);
}
return new Promise((resolve, _reject) => {
let interval = timeout / 100;
if (interval <= 0) {
interval = 10;
}
const timerID = setInterval(() => {
if (this._inFlight === 0) {
resolve(true);
clearInterval(timerID);
return;
}
if (timeout <= 0) {
resolve(false);
clearInterval(timerID);
return;
}
timeout -= interval;
}, interval);
});
}
_instrument() {
const mods = ['pg', 'mysql', 'mysql2', 'redis', 'http', 'https'];
for (let modName of mods) {
try {
const mod = require(`${modName}.js`);
const airbrakeMod = require(`@airbrake/node/dist/instrumentation/${modName}.js`);
airbrakeMod.patch(mod, this);
} catch (_) {}
}
}
}
================================================
FILE: packages/node/src/scope.ts
================================================
import { Scope } from '@airbrake/browser';
import * as asyncHooks from 'async_hooks';
export { Scope };
export class ScopeManager {
_asyncHook: asyncHooks.AsyncHook;
_scopes: { [id: number]: Scope } = {};
constructor() {
this._asyncHook = asyncHooks
.createHook({
init: this._init.bind(this),
destroy: this._destroy.bind(this),
promiseResolve: this._destroy.bind(this),
})
.enable();
}
setActive(scope: Scope) {
const eid = asyncHooks.executionAsyncId();
this._scopes[eid] = scope;
}
active(): Scope {
const eid = asyncHooks.executionAsyncId();
return this._scopes[eid];
}
_init(aid: number) {
this._scopes[aid] = this._scopes[asyncHooks.executionAsyncId()];
}
_destroy(aid: number) {
delete this._scopes[aid];
}
}
================================================
FILE: packages/node/src/version.ts
================================================
export const NOTIFIER_NAME = 'airbrake-js/node';
export const NOTIFIER_VERSION = '2.1.9';
export const NOTIFIER_URL =
'https://github.com/airbrake/airbrake-js/tree/master/packages/node';
================================================
FILE: packages/node/tests/notifier.test.js
================================================
import { Notifier } from '../src/notifier';
describe('Notifier', () => {
describe('configuration', () => {
describe('performanceStats', () => {
test('is enabled by default', () => {
const notifier = new Notifier({
projectId: 1,
projectKey: 'key',
remoteConfig: false,
});
expect(notifier._opt.performanceStats).toEqual(true);
});
test('sets up instrumentation when enabled', () => {
Notifier.prototype._instrument = jest.fn();
const notifier = new Notifier({
projectId: 1,
projectKey: 'key',
remoteConfig: false,
});
expect(notifier._instrument.mock.calls.length).toEqual(1);
});
test('can be disabled', () => {
const notifier = new Notifier({
projectId: 1,
projectKey: 'key',
performanceStats: false,
remoteConfig: false,
});
expect(notifier._opt.performanceStats).toEqual(false);
});
test('does not set up instrumentation when disabled', () => {
Notifier.prototype._instrument = jest.fn();
const notifier = new Notifier({
projectId: 1,
projectKey: 'key',
performanceStats: false,
remoteConfig: false,
});
expect(notifier._instrument.mock.calls.length).toEqual(0);
});
});
});
});
================================================
FILE: packages/node/tests/routes.test.js
================================================
import { Notifier } from '../src/notifier';
describe('Routes', () => {
const opt = {
projectId: 1,
projectKey: 'test',
remoteConfig: false,
};
let notifier;
let routes;
let req;
beforeEach(() => {
notifier = new Notifier(opt);
routes = notifier.routes;
req = routes.start('GET', '/projects/:id');
req.statusCode = 200;
req.contentType = 'application/json';
req.startTime = new Date(1);
req.endTime = new Date(1000);
});
it('collects metrics to report to Airbrake', () => {
routes.notify(req);
clearTimeout(routes._routes._timer);
clearTimeout(routes._breakdowns._timer);
let m = JSON.parse(JSON.stringify(routes._routes._m));
expect(m).toStrictEqual({
'{"method":"GET","route":"/projects/:id","statusCode":200,"time":"1970-01-01T00:00:00.000Z"}': {
count: 1,
sum: 999,
sumsq: 998001,
tdigestCentroids: { count: [1], mean: [999] },
},
});
});
it('does not collect metrics that are filtered', () => {
notifier.addPerformanceFilter(() => null);
routes.notify(req);
clearTimeout(routes._routes._timer);
clearTimeout(routes._breakdowns._timer);
expect(routes._routes._m).toStrictEqual({});
});
});
================================================
FILE: packages/node/tsconfig.cjs.json
================================================
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}
================================================
FILE: packages/node/tsconfig.esm.json
================================================
{
"extends": "../../tsconfig.esm.json",
"compilerOptions": {
"outDir": "esm"
},
"include": ["src"]
}
================================================
FILE: packages/node/tsconfig.json
================================================
{
"extends": "./tsconfig.cjs.json",
"compilerOptions": {
"rootDir": ".",
},
"include": ["src", "test"]
}
================================================
FILE: packages/node/tslint.json
================================================
{
"extends": ["../../tslint.json"]
}
================================================
FILE: tsconfig.esm.json
================================================
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ES6"
}
}
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"lib": ["ES2015", "DOM"],
"moduleResolution": "node",
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"preserveConstEnums": true,
"skipLibCheck": true,
"sourceMap": true,
"target": "ES5"
}
}
================================================
FILE: tslint.json
================================================
{
"extends": [
"tslint:latest",
"tslint-config-prettier",
"tslint-plugin-prettier"
],
"rules": {
"max-classes-per-file": false,
"member-access": false,
"member-ordering": false,
"no-bitwise": false,
"no-console": [true, "log"],
"no-empty": false,
"no-implicit-dependencies": false,
"no-shadowed-variable": [true, { "temporalDeadZone": false }],
"no-submodule-imports": [true, "promise-polyfill/src/polyfill"],
"no-var-requires": false,
"object-literal-sort-keys": false,
"prefer-const": false,
"prettier": true,
"variable-name": [true, "allow-leading-underscore"]
}
}