@sentry/jest-environment
Version:
Automatic performance instrumentation for your Jest tests.
340 lines (299 loc) • 9.87 kB
JavaScript
let sentryModule;
const SPAN_STATUS_OK = {code: 1, message: 'ok'};
const SPAN_STATUS_ERROR = {code: 2, message: 'internal_error'};
const ROOT_DESCRIBE_BLOCK = 'ROOT_DESCRIBE_BLOCK';
const DEFAULT_MIN_HOOK_DURATION_MS = 5;
const timestampInSeconds = () => (performance.timeOrigin + performance.now()) / 1_000;
function loadSentry() {
sentryModule ??= require('@sentry/node');
return sentryModule;
}
function isWatchMode() {
return process.argv.includes('--watch') || process.argv.includes('--watchAll');
}
function getFullName(entry) {
const names = [];
for (
let current = entry;
current && current.name !== ROOT_DESCRIBE_BLOCK;
current = current.parent
) {
names.push(current.name);
}
return names.reverse().join(' > ');
}
function getTimeoutAttribute(timeout) {
return timeout == null ? {} : {'test.timeout_ms': timeout};
}
function createEnvironment({baseEnvironment = require('jest-environment-jsdom')} = {}) {
const {TestEnvironment: BaseEnvironment} = baseEnvironment;
return class SentryEnvironment extends BaseEnvironment {
constructor(...args) {
super(...args);
const [config, context] = args;
const options = config.projectConfig.testEnvironmentOptions.sentryConfig;
if (
!options ||
options.init?.dsn === false ||
// Jest does not expose watch mode to test environments directly.
isWatchMode()
) {
return;
}
const {init = {}, minHookDurationMs = DEFAULT_MIN_HOOK_DURATION_MS, tags} = options;
this.sentry = loadSentry();
if (!this.sentry.isInitialized()) {
this.sentry.init(init);
if (tags) {
this.sentry.setTags(tags);
}
}
const {relative, sep} = require('node:path');
this.testPath = relative(process.cwd(), context.testPath).replaceAll(sep, '/');
this.minHookDurationSeconds = minHookDurationMs / 1_000;
this.baseAttributes = {
'test.file': this.testPath,
'test.framework': 'jest',
...(process.env.JEST_WORKER_ID
? {'test.worker_id': process.env.JEST_WORKER_ID}
: {}),
};
this.describeSpans = new Map();
this.testSpans = new Map();
this.testFunctionSpans = new Map();
this.hookStarts = new Map();
this.openSpans = new Set();
}
async setup() {
const setupEnvironment = async () => {
await super.setup();
if (this.dom && !this.global.jsdom) {
this.global.jsdom = this.dom;
}
};
if (!this.sentry) {
await setupEnvironment();
return;
}
this.suiteSpan = this.sentry.startNewTrace(() =>
this.sentry.startInactiveSpan({
op: 'jest test suite',
name: this.testPath,
forceTransaction: true,
attributes: this.baseAttributes,
})
);
this.global.transaction = this.suiteSpan;
this.global.Sentry = this.sentry;
await this.runEnvironmentSpan('setup', setupEnvironment);
}
async teardown() {
const teardownEnvironment = async () => {
this.global.jsdom = undefined;
this.global.Sentry = undefined;
this.global.transaction = undefined;
await super.teardown();
};
try {
if (!this.sentry) {
await teardownEnvironment();
return;
}
this.endOpenSpans();
await this.runEnvironmentSpan('teardown', teardownEnvironment);
} finally {
this.suiteSpan?.end();
this.describeSpans = null;
this.testSpans = null;
this.testFunctionSpans = null;
this.hookStarts = null;
this.openSpans = null;
this.baseAttributes = null;
this.minHookDurationSeconds = null;
this.testPath = null;
this.suiteSpan = null;
this.sentry = null;
}
}
runEnvironmentSpan(op, callback) {
return this.sentry.startSpan(
{name: this.testPath, op, parentSpan: this.suiteSpan},
callback
);
}
startTrackedSpan(options, parentSpan) {
const span = this.sentry.startInactiveSpan({
...options,
parentSpan,
});
this.openSpans.add(span);
return span;
}
endTrackedSpan(span, {attributes, endTime, error, isOkay} = {}) {
if (!span || !this.openSpans.has(span)) {
return;
}
try {
if (attributes) {
span.setAttributes(attributes);
}
if (error) {
this.sentry.withActiveSpan(span, () => {
this.sentry.captureException(error);
});
}
if (typeof isOkay === 'boolean') {
span.setStatus(isOkay ? SPAN_STATUS_OK : SPAN_STATUS_ERROR);
}
} finally {
span.end(endTime);
this.openSpans.delete(span);
}
}
endOpenSpans() {
for (const span of [...this.openSpans].reverse()) {
this.endTrackedSpan(span);
}
}
handleTestEvent(event, state) {
if (!this.sentry) {
return;
}
switch (event.name) {
case 'run_describe_start': {
const {describeBlock} = event;
if (describeBlock.name === ROOT_DESCRIBE_BLOCK) {
this.describeSpans.set(describeBlock, this.suiteSpan);
return;
}
const parentSpan = this.describeSpans.get(describeBlock.parent);
const span = this.startTrackedSpan(
{name: getFullName(describeBlock), op: 'describe'},
parentSpan
);
this.describeSpans.set(describeBlock, span);
return;
}
case 'run_describe_finish': {
const {describeBlock} = event;
const span = this.describeSpans.get(describeBlock);
this.describeSpans.delete(describeBlock);
if (span !== this.suiteSpan) {
this.endTrackedSpan(span);
}
return;
}
case 'test_started': {
const {test} = event;
const parentSpan = this.describeSpans.get(test.parent);
const span = this.startTrackedSpan(
{
forceTransaction: true,
name: getFullName(test),
op: 'jest test',
attributes: {
...this.baseAttributes,
'test.concurrent': Boolean(test.concurrent),
'test.expected_failure': Boolean(test.failing),
'test.invocation': test.invocations ?? 1,
...(test.mode ? {'test.mode': test.mode} : {}),
...getTimeoutAttribute(test.timeout ?? state?.testTimeout),
},
},
parentSpan
);
this.testSpans.set(test, span);
return;
}
case 'test_fn_start': {
const {test} = event;
if (test.errors.length > 0) {
return;
}
const span = this.startTrackedSpan(
{name: getFullName(test), op: 'test-fn'},
this.testSpans.get(test)
);
this.testFunctionSpans.set(test, span);
return;
}
case 'test_fn_success':
case 'test_fn_failure': {
const {test} = event;
const span = this.testFunctionSpans.get(test);
this.testFunctionSpans.delete(test);
this.endTrackedSpan(span, {
error: event.name === 'test_fn_failure' ? event.error : undefined,
isOkay: event.name === 'test_fn_success',
});
return;
}
case 'hook_start': {
const {hook} = event;
const hookStart = {
startTime: timestampInSeconds(),
timeout: hook.timeout ?? state?.testTimeout,
};
this.hookStarts.set(hook, hookStart);
return;
}
case 'hook_success':
case 'hook_failure': {
const {hook} = event;
const hookStart = this.hookStarts.get(hook);
this.hookStarts.delete(hook);
const endTime = timestampInSeconds();
if (
event.name === 'hook_success' &&
hookStart &&
endTime - hookStart.startTime < this.minHookDurationSeconds
) {
return;
}
const parentSpan = event.test
? this.testSpans.get(event.test)
: this.describeSpans.get(hook.parent);
const parentName = getFullName(hook.parent);
const span = this.startTrackedSpan(
{
name: parentName ? `${parentName} > ${hook.type}` : hook.type,
op: hook.type,
startTime: hookStart?.startTime,
attributes: {
'test.hook.type': hook.type,
...getTimeoutAttribute(hookStart?.timeout),
},
},
parentSpan
);
this.endTrackedSpan(span, {
endTime,
error: event.name === 'hook_failure' ? event.error : undefined,
isOkay: event.name === 'hook_success',
});
return;
}
case 'test_done': {
const {test} = event;
const functionSpan = this.testFunctionSpans.get(test);
this.testFunctionSpans.delete(test);
this.endTrackedSpan(functionSpan, {isOkay: false});
const testSpan = this.testSpans.get(test);
this.testSpans.delete(test);
const isOkay = test.errors.length === 0;
this.endTrackedSpan(testSpan, {
attributes: {
'test.assertion_count': test.numPassingAsserts ?? 0,
'test.status': isOkay ? 'passed' : 'failed',
},
isOkay,
});
return;
}
default:
return;
}
}
};
}
module.exports = createEnvironment;