Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/browser/rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export default {
}),
terser({
sourceMap: true,
keep_fnames: new RegExp('.report'),
}),
// visualizer({
// sourcemap: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import * as shimmer from 'shimmer';
import {
InstrumentationBase,
InstrumentationConfig,
} from '@opentelemetry/instrumentation';

import { recordException } from '../';
import { limitLen, getElementXPath } from './utils';

// FIXME take timestamps from events?

const MESSAGE_LIMIT = 1024;

function useful(s) {
return s && s.trim() !== '' && !s.startsWith('[object') && s !== 'error';
}

function stringifyValue(value: unknown) {
if (value === undefined) {
return '(undefined)';
}

return value.toString();
}

export const ERROR_INSTRUMENTATION_NAME = 'errors';
export const ERROR_INSTRUMENTATION_VERSION = '1';

export class HyperDXErrorInstrumentation extends InstrumentationBase {
private readonly _consoleErrorHandler = (original: Console['error']) => {
return (...args: any[]) => {
this.report('console.error', args);
return original.apply(this, args);
};
};

private readonly _unhandledRejectionListener = (
event: PromiseRejectionEvent,
) => {
this.report('unhandledrejection', event.reason);
};

private readonly _errorListener = (event: ErrorEvent) => {
this.report('onerror', event);
};

private readonly _documentErrorListener = (event: ErrorEvent) => {
this.report('eventListener.error', event);
};

constructor(config: InstrumentationConfig) {
super(ERROR_INSTRUMENTATION_NAME, ERROR_INSTRUMENTATION_VERSION, config);
}

// eslint-disable-next-line @typescript-eslint/no-empty-function
init(): void {}

enable(): void {
shimmer.wrap(console, 'error', this._consoleErrorHandler);
window.addEventListener(
'unhandledrejection',
this._unhandledRejectionListener,
);
window.addEventListener('error', this._errorListener);
document.documentElement.addEventListener(
'error',
this._documentErrorListener,
{ capture: true },
);
}

disable(): void {
shimmer.unwrap(console, 'error');
window.removeEventListener(
'unhandledrejection',
this._unhandledRejectionListener,
);
window.removeEventListener('error', this._errorListener);
document.documentElement.removeEventListener(
'error',
this._documentErrorListener,
{ capture: true },
);
}

protected reportError(source: string, err: Error): void {
const msg = err.message || err.toString();
if (!useful(msg) && !err.stack) {
return;
}

recordException(err, {}, this.tracer);
}

protected reportString(
source: string,
message: string,
firstError?: Error,
): void {
if (!useful(message)) {
return;
}

const e = new Error(limitLen(message, MESSAGE_LIMIT));
if (firstError && firstError.stack && useful(firstError.stack)) {
e.stack = firstError.stack;
}

recordException(e, {}, this.tracer);
}

protected reportErrorEvent(source: string, ev: ErrorEvent): void {
if (ev.error) {
this.report(source, ev.error);
} else if (ev.message) {
this.report(source, ev.message);
}
}

protected reportEvent(source: string, ev: Event): void {
// FIXME consider other sources of global 'error' DOM callback - what else can be captured here?
if (!ev.target && !useful(ev.type)) {
return;
}

const now = Date.now();
const span = this.tracer.startSpan(source, { startTime: now });
if (ev.target) {
// TODO: find types to match this
span.setAttribute('target_element', (ev.target as any).tagName);
span.setAttribute('target_xpath', getElementXPath(ev.target, true));
span.setAttribute('target_src', (ev.target as any).src);
}
span.end(now);

recordException(ev, {}, this.tracer, span);
}

public report(
source: string,
arg: string | Event | ErrorEvent | Array<any>,
): void {
if (Array.isArray(arg) && arg.length === 0) {
return;
}
if (arg instanceof Array && arg.length === 1) {
arg = arg[0];
}
if (arg instanceof Error) {
this.reportError(source, arg);
} else if (arg instanceof ErrorEvent) {
this.reportErrorEvent(source, arg);
} else if (arg instanceof Event) {
this.reportEvent(source, arg);
} else if (typeof arg === 'string') {
this.reportString(source, arg);
} else if (arg instanceof Array) {
// if any arguments are Errors then add the stack trace even though the message is handled differently
const firstError = arg.find((x) => x instanceof Error);
this.reportString(
source,
arg.map((x) => stringifyValue(x)).join(' '),
firstError,
);
} else {
this.reportString(source, stringifyValue(arg)); // FIXME or JSON.stringify?
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
export function limitLen(s: string, cap: number): string {
if (s.length > cap) {
return s.substring(0, cap);
} else {
return s;
}
}

// from: https://github.com/open-telemetry/opentelemetry-js/blob/812c774998fb60a0c666404ae71b1d508e0568f4/packages/opentelemetry-sdk-trace-web/src/utils.ts#L365
/**
* Get element XPath
* @param target - target element
* @param optimised - when id attribute of element is present the xpath can be
* simplified to contain id
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
export function getElementXPath(target: any, optimised?: boolean): string {
if (target.nodeType === Node.DOCUMENT_NODE) {
return '/';
}
const targetValue = getNodeValue(target, optimised);
if (optimised && targetValue.indexOf('@id') > 0) {
return targetValue;
}
let xpath = '';
if (target.parentNode) {
xpath += getElementXPath(target.parentNode, false);
}
xpath += targetValue;

return xpath;
}

// from: https://github.com/open-telemetry/opentelemetry-js/blob/812c774998fb60a0c666404ae71b1d508e0568f4/packages/opentelemetry-sdk-trace-web/src/utils.ts#L365
/**
* get node value for xpath
* @param target
* @param optimised
*/
function getNodeValue(target: HTMLElement, optimised?: boolean): string {
const nodeType = target.nodeType;
const index = getNodeIndex(target);
let nodeValue = '';
if (nodeType === Node.ELEMENT_NODE) {
const id = target.getAttribute('id');
if (optimised && id) {
return `//*[@id="${id}"]`;
}
nodeValue = target.localName;
} else if (
nodeType === Node.TEXT_NODE ||
nodeType === Node.CDATA_SECTION_NODE
) {
nodeValue = 'text()';
} else if (nodeType === Node.COMMENT_NODE) {
nodeValue = 'comment()';
} else {
return '';
}
// if index is 1 it can be omitted in xpath
if (nodeValue && index > 1) {
return `/${nodeValue}[${index}]`;
}
return `/${nodeValue}`;
}

// from: https://github.com/open-telemetry/opentelemetry-js/blob/812c774998fb60a0c666404ae71b1d508e0568f4/packages/opentelemetry-sdk-trace-web/src/utils.ts#L365
/**
* get node index within the siblings
* @param target
*/
function getNodeIndex(target: HTMLElement): number {
if (!target.parentNode) {
return 0;
}
const allowedTypes = [target.nodeType];
if (target.nodeType === Node.CDATA_SECTION_NODE) {
allowedTypes.push(Node.TEXT_NODE);
}
let elements = Array.from(target.parentNode.childNodes);
elements = elements.filter((element: Node) => {
const localName = (element as HTMLElement).localName;
return (
allowedTypes.indexOf(element.nodeType) >= 0 &&
localName === target.localName
);
});
if (elements.length >= 1) {
return elements.indexOf(target) + 1; // xpath starts from 1
}
// if there are no other similar child xpath doesn't need index
return 0;
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,33 @@ describe('hyperdxIntegration', () => {
lineno: 1,
colno: 88323,
},
{
filename: '',
function: 'test',
lineno: 1,
colno: 88323,
},
{
filename:
'https://www.hyperdx.io/_next/static/chunks/somefile.js',
function: '?',
lineno: 1,
colno: 88323,
},
{
filename:
'https://www.hyperdx.io/_next/static/chunks/somefile.js',
function: 'Lx.report',
lineno: 1,
colno: 88323,
},
{
filename:
'https://www.hyperdx.io/_next/static/chunks/somefile.js',
function: 'Lx.reportString',
lineno: 1,
colno: 88323,
},
],
},
},
Expand All @@ -61,6 +88,12 @@ describe('hyperdxIntegration', () => {
lineno: 1,
colno: 88323,
},
{
filename: '',
function: 'test',
lineno: 1,
colno: 88323,
},
]);
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Event, IntegrationFn } from '@sentry/types';
import type { Event, IntegrationFn, StackFrame } from '@sentry/types';
import { defineIntegration } from '@sentry/core';

const INTEGRATION_NAME = 'HyperDX';
Expand All @@ -20,10 +20,40 @@ export const _hyperdxIntegration = ((options: HyperDXOptions = {}) => {
if (exceptions && exceptions.length > 0) {
for (const exception of exceptions) {
if (exception.stacktrace?.frames) {
exception.stacktrace.frames = exception.stacktrace.frames.filter(
(frame) =>
frame.filename && !frame.filename.includes('framework-'),
);
const _filteredFrames: StackFrame[] = [];
let shouldRemoveNextFrameIfSameFile: string | null = null;

for (let i = exception.stacktrace.frames.length - 1; i >= 0; i--) {
const frame = exception.stacktrace.frames[i];
// remove all frames from framework-*.js (nextjs)
if (frame.filename?.includes('framework-')) {
continue;
// remove frames caused by SDK
} else if (
frame.function?.endsWith('.reportString') ||
frame.function?.endsWith('.reportError') ||
frame.function?.endsWith('.reportErrorEvent') ||
frame.function?.endsWith('.reportEvent')
) {
continue;
// console.errors are caught and reported by the SDK in this sequence:
// anon fn -> reportString -> report
// this condition removes the anon fn after .reportString ans .report frames
} else if (frame.function?.endsWith('.report')) {
shouldRemoveNextFrameIfSameFile = frame.filename;
continue;
} else if (
frame.function?.length <= 1 &&
shouldRemoveNextFrameIfSameFile &&
frame.filename === shouldRemoveNextFrameIfSameFile
) {
shouldRemoveNextFrameIfSameFile = null;
continue;
}
_filteredFrames.unshift(frame);
}

exception.stacktrace.frames = _filteredFrames;
}
}
}
Expand Down