diff --git a/CHANGELOG.md b/CHANGELOG.md
index eb8cc846b983..83c74ac68f4e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,21 @@
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
+## 7.63.0
+
+- build(deps): bump @opentelemetry/instrumentation from 0.41.0 to 0.41.2
+- feat(eventbuilder): Export `exceptionFromError` for use in hybrid SDKs (#8766)
+- feat(node-experimental): Re-export from node (#8786)
+- feat(tracing): Add db connection attributes for mysql spans (#8775)
+- feat(tracing): Add db connection attributes for postgres spans (#8778)
+- feat(tracing): Improve data collection for mongodb spans (#8774)
+- fix(nextjs): Execute sentry config independently of `autoInstrumentServerFunctions` and `autoInstrumentAppDirectory` (#8781)
+- fix(replay): Ensure we do not flush if flush took too long (#8784)
+- fix(replay): Ensure we do not try to flush when we force stop replay (#8783)
+- fix(replay): Fix `hasCheckout` handling (#8782)
+- fix(replay): Handle multiple clicks in a short time (#8773)
+- ref(replay): Skip events being added too long after initial segment (#8768)
+
## 7.62.0
### Important Changes
diff --git a/packages/browser-integration-tests/suites/replay/eventBufferError/template.html b/packages/browser-integration-tests/suites/replay/eventBufferError/template.html
new file mode 100644
index 000000000000..24fc4828baf1
--- /dev/null
+++ b/packages/browser-integration-tests/suites/replay/eventBufferError/template.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/browser-integration-tests/suites/replay/eventBufferError/test.ts b/packages/browser-integration-tests/suites/replay/eventBufferError/test.ts
new file mode 100644
index 000000000000..10e9ad6f7196
--- /dev/null
+++ b/packages/browser-integration-tests/suites/replay/eventBufferError/test.ts
@@ -0,0 +1,89 @@
+import { expect } from '@playwright/test';
+
+import { sentryTest } from '../../../utils/fixtures';
+import { envelopeRequestParser } from '../../../utils/helpers';
+import {
+ getDecompressedRecordingEvents,
+ getReplaySnapshot,
+ isReplayEvent,
+ REPLAY_DEFAULT_FLUSH_MAX_DELAY,
+ shouldSkipReplayTest,
+ waitForReplayRequest,
+} from '../../../utils/replayHelpers';
+
+sentryTest(
+ 'should stop recording when running into eventBuffer error',
+ async ({ getLocalTestPath, page, forceFlushReplay }) => {
+ if (shouldSkipReplayTest()) {
+ sentryTest.skip();
+ }
+
+ await page.route('https://dsn.ingest.sentry.io/**/*', route => {
+ return route.fulfill({
+ status: 200,
+ });
+ });
+
+ const url = await getLocalTestPath({ testDir: __dirname });
+ await page.goto(url);
+
+ await waitForReplayRequest(page);
+ const replay = await getReplaySnapshot(page);
+ expect(replay._isEnabled).toBe(true);
+
+ await forceFlushReplay();
+
+ let called = 0;
+
+ await page.route('https://dsn.ingest.sentry.io/**/*', route => {
+ const event = envelopeRequestParser(route.request());
+
+ // We only want to count replays here
+ if (event && isReplayEvent(event)) {
+ const events = getDecompressedRecordingEvents(route.request());
+ // this makes sure we ignore e.g. mouse move events which can otherwise lead to flakes
+ if (events.length > 0) {
+ called++;
+ }
+ }
+
+ return route.fulfill({
+ status: 200,
+ });
+ });
+
+ called = 0;
+
+ /**
+ * We test the following here:
+ * 1. First click should add an event (so the eventbuffer is not empty)
+ * 2. Second click should throw an error in eventBuffer (which should lead to stopping the replay)
+ * 3. Nothing should be sent to API, as we stop the replay due to the eventBuffer error.
+ */
+ await page.evaluate(`
+window._count = 0;
+window._addEvent = window.Replay._replay.eventBuffer.addEvent.bind(window.Replay._replay.eventBuffer);
+window.Replay._replay.eventBuffer.addEvent = (...args) => {
+ window._count++;
+ if (window._count === 2) {
+ throw new Error('provoked error');
+ }
+ window._addEvent(...args);
+};
+`);
+
+ void page.click('#button1');
+ void page.click('#button2');
+
+ // Should immediately skip retrying and just cancel, no backoff
+ // This waitForTimeout call should be okay, as we're not checking for any
+ // further network requests afterwards.
+ await page.waitForTimeout(REPLAY_DEFAULT_FLUSH_MAX_DELAY + 100);
+
+ expect(called).toBe(0);
+
+ const replay2 = await getReplaySnapshot(page);
+
+ expect(replay2._isEnabled).toBe(false);
+ },
+);
diff --git a/packages/browser-integration-tests/utils/replayHelpers.ts b/packages/browser-integration-tests/utils/replayHelpers.ts
index cb05baffb62b..5ddcf0c46e05 100644
--- a/packages/browser-integration-tests/utils/replayHelpers.ts
+++ b/packages/browser-integration-tests/utils/replayHelpers.ts
@@ -267,7 +267,7 @@ function getOptionsEvents(replayRequest: Request): CustomRecordingEvent[] {
return getAllCustomRrwebRecordingEvents(events).filter(data => data.tag === 'options');
}
-function getDecompressedRecordingEvents(resOrReq: Request | Response): RecordingSnapshot[] {
+export function getDecompressedRecordingEvents(resOrReq: Request | Response): RecordingSnapshot[] {
const replayRequest = getRequest(resOrReq);
return (
(replayEnvelopeRequestParser(replayRequest, 5) as eventWithTime[])
@@ -302,7 +302,7 @@ const replayEnvelopeRequestParser = (request: Request | null, envelopeIndex = 2)
return envelope[envelopeIndex] as Event;
};
-const replayEnvelopeParser = (request: Request | null): unknown[] => {
+export const replayEnvelopeParser = (request: Request | null): unknown[] => {
// https://develop.sentry.dev/sdk/envelopes/
const envelopeBytes = request?.postDataBuffer() || '';
diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts
index 1f7117620c93..03533bdbc90d 100644
--- a/packages/browser/src/exports.ts
+++ b/packages/browser/src/exports.ts
@@ -61,7 +61,7 @@ export {
opera11StackLineParser,
winjsStackLineParser,
} from './stack-parsers';
-export { eventFromException, eventFromMessage } from './eventbuilder';
+export { eventFromException, eventFromMessage, exceptionFromError } from './eventbuilder';
export { createUserFeedbackEnvelope } from './userfeedback';
export { defaultIntegrations, forceLoad, init, onLoad, showReportDialog, wrap, captureUserFeedback } from './sdk';
export { GlobalHandlers, TryCatch, Breadcrumbs, LinkedErrors, HttpContext, Dedupe } from './integrations';
diff --git a/packages/nextjs/rollup.npm.config.js b/packages/nextjs/rollup.npm.config.js
index f9c498c8f39e..ce15b951235e 100644
--- a/packages/nextjs/rollup.npm.config.js
+++ b/packages/nextjs/rollup.npm.config.js
@@ -24,11 +24,12 @@ export default [
...makeNPMConfigVariants(
makeBaseNPMConfig({
entrypoints: [
- 'src/config/templates/pageWrapperTemplate.ts',
'src/config/templates/apiWrapperTemplate.ts',
'src/config/templates/middlewareWrapperTemplate.ts',
- 'src/config/templates/serverComponentWrapperTemplate.ts',
+ 'src/config/templates/pageWrapperTemplate.ts',
'src/config/templates/requestAsyncStorageShim.ts',
+ 'src/config/templates/sentryInitWrapperTemplate.ts',
+ 'src/config/templates/serverComponentWrapperTemplate.ts',
],
packageSpecificConfig: {
@@ -47,6 +48,7 @@ export default [
external: [
'@sentry/nextjs',
'next/dist/client/components/request-async-storage',
+ '__SENTRY_CONFIG_IMPORT_PATH__',
'__SENTRY_WRAPPING_TARGET_FILE__',
'__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM__',
],
diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts
index e4d58c579420..f2e07ab6537d 100644
--- a/packages/nextjs/src/config/loaders/wrappingLoader.ts
+++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts
@@ -30,6 +30,9 @@ const requestAsyncStorageShimPath = path.resolve(__dirname, '..', 'templates', '
const requestAsyncStorageModuleExists = moduleExists(NEXTJS_REQUEST_ASYNC_STORAGE_MODULE_PATH);
let showedMissingAsyncStorageModuleWarning = false;
+const sentryInitWrapperTemplatePath = path.resolve(__dirname, '..', 'templates', 'sentryInitWrapperTemplate.js');
+const sentryInitWrapperTemplateCode = fs.readFileSync(sentryInitWrapperTemplatePath, { encoding: 'utf8' });
+
const serverComponentWrapperTemplatePath = path.resolve(
__dirname,
'..',
@@ -43,7 +46,7 @@ type LoaderOptions = {
appDir: string;
pageExtensionRegex: string;
excludeServerRoutes: Array;
- wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component';
+ wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component' | 'sentry-init';
sentryConfigFilePath?: string;
vercelCronsConfig?: VercelCronsConfig;
};
@@ -83,7 +86,23 @@ export default function wrappingLoader(
let templateCode: string;
- if (wrappingTargetKind === 'page' || wrappingTargetKind === 'api-route') {
+ if (wrappingTargetKind === 'sentry-init') {
+ templateCode = sentryInitWrapperTemplateCode;
+
+ // Absolute paths to the sentry config do not work with Windows: https://github.com/getsentry/sentry-javascript/issues/8133
+ // Se we need check whether `this.resourcePath` is absolute because there is no contract by webpack that says it is absolute.
+ // Examples where `this.resourcePath` could possibly be non-absolute are virtual modules.
+ if (sentryConfigFilePath && path.isAbsolute(this.resourcePath)) {
+ const sentryConfigImportPath = path
+ .relative(path.dirname(this.resourcePath), sentryConfigFilePath)
+ .replace(/\\/g, '/');
+ templateCode = templateCode.replace(/__SENTRY_CONFIG_IMPORT_PATH__/g, sentryConfigImportPath);
+ } else {
+ // Bail without doing any wrapping
+ this.callback(null, userCode, userModuleSourceMap);
+ return;
+ }
+ } else if (wrappingTargetKind === 'page' || wrappingTargetKind === 'api-route') {
// Get the parameterized route name from this page's filepath
const parameterizedPagesRoute = path.posix
.normalize(
@@ -207,15 +226,6 @@ export default function wrappingLoader(
throw new Error(`Invariant: Could not get template code of unknown kind "${wrappingTargetKind}"`);
}
- // We check whether `this.resourcePath` is absolute because there is no contract by webpack that says it is absolute,
- // however we can only create relative paths to the sentry config from absolute paths.Examples where this could possibly be non - absolute are virtual modules.
- if (sentryConfigFilePath && path.isAbsolute(this.resourcePath)) {
- const sentryConfigImportPath = path
- .relative(path.dirname(this.resourcePath), sentryConfigFilePath) // Absolute paths do not work with Windows: https://github.com/getsentry/sentry-javascript/issues/8133
- .replace(/\\/g, '/');
- templateCode = `import "${sentryConfigImportPath}";\n`.concat(templateCode);
- }
-
// Replace the import path of the wrapping target in the template with a path that the `wrapUserCode` function will understand.
templateCode = templateCode.replace(/__SENTRY_WRAPPING_TARGET_FILE__/g, WRAPPING_TARGET_MODULE_NAME);
diff --git a/packages/nextjs/src/config/templates/sentryInitWrapperTemplate.ts b/packages/nextjs/src/config/templates/sentryInitWrapperTemplate.ts
new file mode 100644
index 000000000000..1720c3b62672
--- /dev/null
+++ b/packages/nextjs/src/config/templates/sentryInitWrapperTemplate.ts
@@ -0,0 +1,11 @@
+// @ts-ignore This will be replaced with the user's sentry config gile
+// eslint-disable-next-line import/no-unresolved
+import '__SENTRY_CONFIG_IMPORT_PATH__';
+
+// @ts-ignore This is the file we're wrapping
+// eslint-disable-next-line import/no-unresolved
+export * from '__SENTRY_WRAPPING_TARGET_FILE__';
+
+// @ts-ignore This is the file we're wrapping
+// eslint-disable-next-line import/no-unresolved
+export { default } from '__SENTRY_WRAPPING_TARGET_FILE__';
diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts
index 49f450bb27a4..d711fef5c14f 100644
--- a/packages/nextjs/src/config/webpack.ts
+++ b/packages/nextjs/src/config/webpack.ts
@@ -140,19 +140,45 @@ export function constructWebpackConfigFunction(
return path.normalize(absoluteResourcePath);
};
+ const isPageResource = (resourcePath: string): boolean => {
+ const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath);
+ return (
+ normalizedAbsoluteResourcePath.startsWith(pagesDirPath + path.sep) &&
+ !normalizedAbsoluteResourcePath.startsWith(apiRoutesPath + path.sep) &&
+ dotPrefixedPageExtensions.some(ext => normalizedAbsoluteResourcePath.endsWith(ext))
+ );
+ };
+
+ const isApiRouteResource = (resourcePath: string): boolean => {
+ const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath);
+ return (
+ normalizedAbsoluteResourcePath.startsWith(apiRoutesPath + path.sep) &&
+ dotPrefixedPageExtensions.some(ext => normalizedAbsoluteResourcePath.endsWith(ext))
+ );
+ };
+
+ const isMiddlewareResource = (resourcePath: string): boolean => {
+ const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath);
+ return normalizedAbsoluteResourcePath === middlewareJsPath || normalizedAbsoluteResourcePath === middlewareTsPath;
+ };
+
+ const isServerComponentResource = (resourcePath: string): boolean => {
+ const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath);
+
+ // ".js, .jsx, or .tsx file extensions can be used for Pages"
+ // https://beta.nextjs.org/docs/routing/pages-and-layouts#pages:~:text=.js%2C%20.jsx%2C%20or%20.tsx%20file%20extensions%20can%20be%20used%20for%20Pages.
+ return (
+ normalizedAbsoluteResourcePath.startsWith(appDirPath + path.sep) &&
+ !!normalizedAbsoluteResourcePath.match(/[\\/](page|layout|loading|head|not-found)\.(js|jsx|tsx)$/)
+ );
+ };
+
if (isServer && userSentryOptions.autoInstrumentServerFunctions !== false) {
// It is very important that we insert our loaders at the beginning of the array because we expect any sort of transformations/transpilations (e.g. TS -> JS) to already have happened.
// Wrap pages
newConfig.module.rules.unshift({
- test: resourcePath => {
- const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath);
- return (
- normalizedAbsoluteResourcePath.startsWith(pagesDirPath + path.sep) &&
- !normalizedAbsoluteResourcePath.startsWith(apiRoutesPath + path.sep) &&
- dotPrefixedPageExtensions.some(ext => normalizedAbsoluteResourcePath.endsWith(ext))
- );
- },
+ test: isPageResource,
use: [
{
loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'),
@@ -190,13 +216,7 @@ export function constructWebpackConfigFunction(
// Wrap api routes
newConfig.module.rules.unshift({
- test: resourcePath => {
- const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath);
- return (
- normalizedAbsoluteResourcePath.startsWith(apiRoutesPath + path.sep) &&
- dotPrefixedPageExtensions.some(ext => normalizedAbsoluteResourcePath.endsWith(ext))
- );
- },
+ test: isApiRouteResource,
use: [
{
loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'),
@@ -211,12 +231,7 @@ export function constructWebpackConfigFunction(
// Wrap middleware
newConfig.module.rules.unshift({
- test: resourcePath => {
- const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath);
- return (
- normalizedAbsoluteResourcePath === middlewareJsPath || normalizedAbsoluteResourcePath === middlewareTsPath
- );
- },
+ test: isMiddlewareResource,
use: [
{
loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'),
@@ -232,14 +247,28 @@ export function constructWebpackConfigFunction(
if (isServer && userSentryOptions.autoInstrumentAppDirectory !== false) {
// Wrap page server components
newConfig.module.rules.unshift({
- test: resourcePath => {
- const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath);
+ test: isServerComponentResource,
+ use: [
+ {
+ loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'),
+ options: {
+ ...staticWrappingLoaderOptions,
+ wrappingTargetKind: 'server-component',
+ },
+ },
+ ],
+ });
+ }
- // ".js, .jsx, or .tsx file extensions can be used for Pages"
- // https://beta.nextjs.org/docs/routing/pages-and-layouts#pages:~:text=.js%2C%20.jsx%2C%20or%20.tsx%20file%20extensions%20can%20be%20used%20for%20Pages.
+ if (isServer) {
+ // Import the Sentry config in every user file
+ newConfig.module.rules.unshift({
+ test: resourcePath => {
return (
- normalizedAbsoluteResourcePath.startsWith(appDirPath + path.sep) &&
- !!normalizedAbsoluteResourcePath.match(/[\\/](page|layout|loading|head|not-found)\.(js|jsx|tsx)$/)
+ isPageResource(resourcePath) ||
+ isApiRouteResource(resourcePath) ||
+ isMiddlewareResource(resourcePath) ||
+ isServerComponentResource(resourcePath)
);
},
use: [
@@ -247,7 +276,7 @@ export function constructWebpackConfigFunction(
loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'),
options: {
...staticWrappingLoaderOptions,
- wrappingTargetKind: 'server-component',
+ wrappingTargetKind: 'sentry-init',
},
},
],
diff --git a/packages/node-experimental/src/index.ts b/packages/node-experimental/src/index.ts
index 74108e528513..3c246bdf2b97 100644
--- a/packages/node-experimental/src/index.ts
+++ b/packages/node-experimental/src/index.ts
@@ -11,3 +11,66 @@ export { init } from './sdk/init';
export { INTEGRATIONS as Integrations };
export { getAutoPerformanceIntegrations } from './integrations/getAutoPerformanceIntegrations';
export * as Handlers from './sdk/handlers';
+
+export {
+ makeNodeTransport,
+ defaultStackParser,
+ getSentryRelease,
+ addRequestDataToEvent,
+ DEFAULT_USER_INCLUDES,
+ extractRequestData,
+ deepReadDirSync,
+ getModuleFromFilename,
+ addGlobalEventProcessor,
+ addBreadcrumb,
+ captureException,
+ captureEvent,
+ captureMessage,
+ close,
+ configureScope,
+ createTransport,
+ extractTraceparentData,
+ flush,
+ getActiveTransaction,
+ getHubFromCarrier,
+ getCurrentHub,
+ Hub,
+ lastEventId,
+ makeMain,
+ runWithAsyncContext,
+ Scope,
+ startTransaction,
+ SDK_VERSION,
+ setContext,
+ setExtra,
+ setExtras,
+ setTag,
+ setTags,
+ setUser,
+ spanStatusfromHttpCode,
+ trace,
+ withScope,
+ captureCheckIn,
+} from '@sentry/node';
+
+export type {
+ SpanStatusType,
+ TransactionNamingScheme,
+ AddRequestDataToEventOptions,
+ Breadcrumb,
+ BreadcrumbHint,
+ PolymorphicRequest,
+ Request,
+ SdkInfo,
+ Event,
+ EventHint,
+ Exception,
+ Session,
+ SeverityLevel,
+ Span,
+ StackFrame,
+ Stacktrace,
+ Thread,
+ Transaction,
+ User,
+} from '@sentry/node';
diff --git a/packages/node-integration-tests/suites/tracing-new/auto-instrument/mongodb/test.ts b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mongodb/test.ts
index 8511841c6da8..2d2ec05553e3 100644
--- a/packages/node-integration-tests/suites/tracing-new/auto-instrument/mongodb/test.ts
+++ b/packages/node-integration-tests/suites/tracing-new/auto-instrument/mongodb/test.ts
@@ -30,56 +30,56 @@ conditionalTest({ min: 12 })('MongoDB Test', () => {
spans: [
{
data: {
- collectionName: 'movies',
- dbName: 'admin',
- namespace: 'admin.movies',
- doc: '{"title":"Rick and Morty"}',
'db.system': 'mongodb',
+ 'db.name': 'admin',
+ 'db.operation': 'insertOne',
+ 'db.mongodb.collection': 'movies',
+ 'db.mongodb.doc': '{"title":"Rick and Morty"}',
},
description: 'insertOne',
op: 'db',
},
{
data: {
- collectionName: 'movies',
- dbName: 'admin',
- namespace: 'admin.movies',
- query: '{"title":"Back to the Future"}',
'db.system': 'mongodb',
+ 'db.name': 'admin',
+ 'db.operation': 'findOne',
+ 'db.mongodb.collection': 'movies',
+ 'db.mongodb.query': '{"title":"Back to the Future"}',
},
description: 'findOne',
op: 'db',
},
{
data: {
- collectionName: 'movies',
- dbName: 'admin',
- namespace: 'admin.movies',
- filter: '{"title":"Back to the Future"}',
- update: '{"$set":{"title":"South Park"}}',
'db.system': 'mongodb',
+ 'db.name': 'admin',
+ 'db.operation': 'updateOne',
+ 'db.mongodb.collection': 'movies',
+ 'db.mongodb.filter': '{"title":"Back to the Future"}',
+ 'db.mongodb.update': '{"$set":{"title":"South Park"}}',
},
description: 'updateOne',
op: 'db',
},
{
data: {
- collectionName: 'movies',
- dbName: 'admin',
- namespace: 'admin.movies',
- query: '{"title":"South Park"}',
'db.system': 'mongodb',
+ 'db.name': 'admin',
+ 'db.operation': 'findOne',
+ 'db.mongodb.collection': 'movies',
+ 'db.mongodb.query': '{"title":"South Park"}',
},
description: 'findOne',
op: 'db',
},
{
data: {
- collectionName: 'movies',
- dbName: 'admin',
- namespace: 'admin.movies',
- query: '{"title":"South Park"}',
'db.system': 'mongodb',
+ 'db.name': 'admin',
+ 'db.operation': 'find',
+ 'db.mongodb.collection': 'movies',
+ 'db.mongodb.query': '{"title":"South Park"}',
},
description: 'find',
op: 'db',
diff --git a/packages/node-integration-tests/suites/tracing/auto-instrument/mongodb/test.ts b/packages/node-integration-tests/suites/tracing/auto-instrument/mongodb/test.ts
index 8511841c6da8..2d2ec05553e3 100644
--- a/packages/node-integration-tests/suites/tracing/auto-instrument/mongodb/test.ts
+++ b/packages/node-integration-tests/suites/tracing/auto-instrument/mongodb/test.ts
@@ -30,56 +30,56 @@ conditionalTest({ min: 12 })('MongoDB Test', () => {
spans: [
{
data: {
- collectionName: 'movies',
- dbName: 'admin',
- namespace: 'admin.movies',
- doc: '{"title":"Rick and Morty"}',
'db.system': 'mongodb',
+ 'db.name': 'admin',
+ 'db.operation': 'insertOne',
+ 'db.mongodb.collection': 'movies',
+ 'db.mongodb.doc': '{"title":"Rick and Morty"}',
},
description: 'insertOne',
op: 'db',
},
{
data: {
- collectionName: 'movies',
- dbName: 'admin',
- namespace: 'admin.movies',
- query: '{"title":"Back to the Future"}',
'db.system': 'mongodb',
+ 'db.name': 'admin',
+ 'db.operation': 'findOne',
+ 'db.mongodb.collection': 'movies',
+ 'db.mongodb.query': '{"title":"Back to the Future"}',
},
description: 'findOne',
op: 'db',
},
{
data: {
- collectionName: 'movies',
- dbName: 'admin',
- namespace: 'admin.movies',
- filter: '{"title":"Back to the Future"}',
- update: '{"$set":{"title":"South Park"}}',
'db.system': 'mongodb',
+ 'db.name': 'admin',
+ 'db.operation': 'updateOne',
+ 'db.mongodb.collection': 'movies',
+ 'db.mongodb.filter': '{"title":"Back to the Future"}',
+ 'db.mongodb.update': '{"$set":{"title":"South Park"}}',
},
description: 'updateOne',
op: 'db',
},
{
data: {
- collectionName: 'movies',
- dbName: 'admin',
- namespace: 'admin.movies',
- query: '{"title":"South Park"}',
'db.system': 'mongodb',
+ 'db.name': 'admin',
+ 'db.operation': 'findOne',
+ 'db.mongodb.collection': 'movies',
+ 'db.mongodb.query': '{"title":"South Park"}',
},
description: 'findOne',
op: 'db',
},
{
data: {
- collectionName: 'movies',
- dbName: 'admin',
- namespace: 'admin.movies',
- query: '{"title":"South Park"}',
'db.system': 'mongodb',
+ 'db.name': 'admin',
+ 'db.operation': 'find',
+ 'db.mongodb.collection': 'movies',
+ 'db.mongodb.query': '{"title":"South Park"}',
},
description: 'find',
op: 'db',
diff --git a/packages/node-integration-tests/suites/tracing/auto-instrument/mysql/test.ts b/packages/node-integration-tests/suites/tracing/auto-instrument/mysql/test.ts
index dbdd658f6ef4..b28c6b613e48 100644
--- a/packages/node-integration-tests/suites/tracing/auto-instrument/mysql/test.ts
+++ b/packages/node-integration-tests/suites/tracing/auto-instrument/mysql/test.ts
@@ -14,6 +14,9 @@ test('should auto-instrument `mysql` package.', async () => {
op: 'db',
data: {
'db.system': 'mysql',
+ 'db.user': 'root',
+ 'server.address': expect.any(String),
+ 'server.port': expect.any(Number),
},
},
@@ -22,6 +25,9 @@ test('should auto-instrument `mysql` package.', async () => {
op: 'db',
data: {
'db.system': 'mysql',
+ 'db.user': 'root',
+ 'server.address': expect.any(String),
+ 'server.port': expect.any(Number),
},
},
],
diff --git a/packages/node-integration-tests/suites/tracing/auto-instrument/pg/test.ts b/packages/node-integration-tests/suites/tracing/auto-instrument/pg/test.ts
index 359e04d7d5f0..a9d4b56b0b3c 100644
--- a/packages/node-integration-tests/suites/tracing/auto-instrument/pg/test.ts
+++ b/packages/node-integration-tests/suites/tracing/auto-instrument/pg/test.ts
@@ -1,6 +1,11 @@
import { assertSentryTransaction, TestEnv } from '../../../../utils';
class PgClient {
+ database?: string = 'test';
+ user?: string = 'user';
+ host?: string = 'localhost';
+ port?: number = 5432;
+
// https://node-postgres.com/api/client#clientquery
public query(_text: unknown, values: unknown, callback?: () => void) {
if (typeof callback === 'function') {
@@ -42,6 +47,10 @@ test('should auto-instrument `pg` package.', async () => {
op: 'db',
data: {
'db.system': 'postgresql',
+ 'db.user': 'user',
+ 'db.name': 'test',
+ 'server.address': 'localhost',
+ 'server.port': 5432,
},
},
{
@@ -49,6 +58,10 @@ test('should auto-instrument `pg` package.', async () => {
op: 'db',
data: {
'db.system': 'postgresql',
+ 'db.user': 'user',
+ 'db.name': 'test',
+ 'server.address': 'localhost',
+ 'server.port': 5432,
},
},
{
@@ -56,6 +69,10 @@ test('should auto-instrument `pg` package.', async () => {
op: 'db',
data: {
'db.system': 'postgresql',
+ 'db.user': 'user',
+ 'db.name': 'test',
+ 'server.address': 'localhost',
+ 'server.port': 5432,
},
},
],
diff --git a/packages/node/src/integrations/context.ts b/packages/node/src/integrations/context.ts
index 3039b5fae58b..5b683d5c688d 100644
--- a/packages/node/src/integrations/context.ts
+++ b/packages/node/src/integrations/context.ts
@@ -1,6 +1,7 @@
/* eslint-disable max-lines */
import type {
AppContext,
+ CloudResourceContext,
Contexts,
CultureContext,
DeviceContext,
@@ -29,6 +30,7 @@ interface ContextOptions {
os?: boolean;
device?: DeviceContextOptions | boolean;
culture?: boolean;
+ cloudResource?: boolean;
}
/** Add node modules / packages to the event */
@@ -48,9 +50,15 @@ export class Context implements Integration {
*/
private _cachedContext: Promise | undefined;
- public constructor(private readonly _options: ContextOptions = { app: true, os: true, device: true, culture: true }) {
- //
- }
+ public constructor(
+ private readonly _options: ContextOptions = {
+ app: true,
+ os: true,
+ device: true,
+ culture: true,
+ cloudResource: true,
+ },
+ ) {}
/**
* @inheritDoc
@@ -73,6 +81,7 @@ export class Context implements Integration {
os: { ...updatedContext.os, ...event.contexts?.os },
device: { ...updatedContext.device, ...event.contexts?.device },
culture: { ...updatedContext.culture, ...event.contexts?.culture },
+ cloud_resource: { ...updatedContext.cloud_resource, ...event.contexts?.cloud_resource },
};
return event;
@@ -120,6 +129,10 @@ export class Context implements Integration {
}
}
+ if (this._options.cloudResource) {
+ contexts.cloud_resource = getCloudResourceContext();
+ }
+
return contexts;
}
}
@@ -380,3 +393,72 @@ async function getLinuxInfo(): Promise {
return linuxInfo;
}
+
+/**
+ * Grabs some information about hosting provider based on best effort.
+ */
+function getCloudResourceContext(): CloudResourceContext | undefined {
+ if (process.env.VERCEL) {
+ // https://vercel.com/docs/concepts/projects/environment-variables/system-environment-variables#system-environment-variables
+ return {
+ 'cloud.provider': 'vercel',
+ 'cloud.region': process.env.VERCEL_REGION,
+ };
+ } else if (process.env.AWS_REGION) {
+ // https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html
+ return {
+ 'cloud.provider': 'aws',
+ 'cloud.region': process.env.AWS_REGION,
+ 'cloud.platform': process.env.AWS_EXECUTION_ENV,
+ };
+ } else if (process.env.GCP_PROJECT) {
+ // https://cloud.google.com/composer/docs/how-to/managing/environment-variables#reserved_variables
+ return {
+ 'cloud.provider': 'gcp',
+ };
+ } else if (process.env.ALIYUN_REGION_ID) {
+ // TODO: find where I found these environment variables - at least gc.github.com returns something
+ return {
+ 'cloud.provider': 'alibaba_cloud',
+ 'cloud.region': process.env.ALIYUN_REGION_ID,
+ };
+ } else if (process.env.WEBSITE_SITE_NAME && process.env.REGION_NAME) {
+ // https://learn.microsoft.com/en-us/azure/app-service/reference-app-settings?tabs=kudu%2Cdotnet#app-environment
+ return {
+ 'cloud.provider': 'azure',
+ 'cloud.region': process.env.REGION_NAME,
+ };
+ } else if (process.env.IBM_CLOUD_REGION) {
+ // TODO: find where I found these environment variables - at least gc.github.com returns something
+ return {
+ 'cloud.provider': 'ibm_cloud',
+ 'cloud.region': process.env.IBM_CLOUD_REGION,
+ };
+ } else if (process.env.TENCENTCLOUD_REGION) {
+ // https://www.tencentcloud.com/document/product/583/32748
+ return {
+ 'cloud.provider': 'tencent_cloud',
+ 'cloud.region': process.env.TENCENTCLOUD_REGION,
+ 'cloud.account.id': process.env.TENCENTCLOUD_APPID,
+ 'cloud.availability_zone': process.env.TENCENTCLOUD_ZONE,
+ };
+ } else if (process.env.NETLIFY) {
+ // https://docs.netlify.com/configure-builds/environment-variables/#read-only-variables
+ return {
+ 'cloud.provider': 'netlify',
+ };
+ } else if (process.env.FLY_REGION) {
+ // https://fly.io/docs/reference/runtime-environment/
+ return {
+ 'cloud.provider': 'fly.io',
+ 'cloud.region': process.env.FLY_REGION,
+ };
+ } else if (process.env.DYNO) {
+ // https://devcenter.heroku.com/articles/dynos#local-environment-variables
+ return {
+ 'cloud.provider': 'heroku',
+ };
+ } else {
+ return undefined;
+ }
+}
diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts
index fb128653870f..b63c49e440e7 100644
--- a/packages/node/src/types.ts
+++ b/packages/node/src/types.ts
@@ -1,9 +1,27 @@
-import type { ClientOptions, Options, SamplingContext } from '@sentry/types';
+import type { ClientOptions, Options, SamplingContext, TracePropagationTargets } from '@sentry/types';
import type { NodeClient } from './client';
import type { NodeTransportOptions } from './transports';
export interface BaseNodeOptions {
+ /**
+ * List of strings/regex controlling to which outgoing requests
+ * the SDK will attach tracing headers.
+ *
+ * By default the SDK will attach those headers to all outgoing
+ * requests. If this option is provided, the SDK will match the
+ * request URL of outgoing requests against the items in this
+ * array, and only attach tracing headers if a match was found.
+ *
+ * @example
+ * ```js
+ * Sentry.init({
+ * tracePropagationTargets: ['api.site.com'],
+ * });
+ * ```
+ */
+ tracePropagationTargets?: TracePropagationTargets;
+
/**
* Sets profiling sample rate when @sentry/profiling-node is installed
*/
diff --git a/packages/replay/src/coreHandlers/handleClick.ts b/packages/replay/src/coreHandlers/handleClick.ts
index cc1d816c5435..c4243c1e7e1b 100644
--- a/packages/replay/src/coreHandlers/handleClick.ts
+++ b/packages/replay/src/coreHandlers/handleClick.ts
@@ -136,6 +136,14 @@ export class ClickDetector implements ReplayClickDetector {
clickCount: 0,
node,
};
+
+ // If there was a click in the last 1s on the same element, ignore it - only keep a single reference per second
+ if (
+ this._clicks.some(click => click.node === newClick.node && Math.abs(click.timestamp - newClick.timestamp) < 1)
+ ) {
+ return;
+ }
+
this._clicks.push(newClick);
// If this is the first new click, set a timeout to check for multi clicks
diff --git a/packages/replay/src/eventBuffer/EventBufferProxy.ts b/packages/replay/src/eventBuffer/EventBufferProxy.ts
index 0b5a6bdfed11..eb49b7229c58 100644
--- a/packages/replay/src/eventBuffer/EventBufferProxy.ts
+++ b/packages/replay/src/eventBuffer/EventBufferProxy.ts
@@ -99,13 +99,15 @@ export class EventBufferProxy implements EventBuffer {
/** Switch the used buffer to the compression worker. */
private async _switchToCompressionWorker(): Promise {
- const { events } = this._fallback;
+ const { events, hasCheckout } = this._fallback;
const addEventPromises: Promise[] = [];
for (const event of events) {
addEventPromises.push(this._compression.addEvent(event));
}
+ this._compression.hasCheckout = hasCheckout;
+
// We switch over to the new buffer immediately - any further events will be added
// after the previously buffered ones
this._used = this._compression;
diff --git a/packages/replay/src/integration.ts b/packages/replay/src/integration.ts
index 15a39391636c..e4c17ea04ba1 100644
--- a/packages/replay/src/integration.ts
+++ b/packages/replay/src/integration.ts
@@ -263,7 +263,7 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
return Promise.resolve();
}
- return this._replay.stop();
+ return this._replay.stop({ forceFlush: this._replay.recordingMode === 'session' });
}
/**
diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts
index dd82e26f3d57..6bba4eb66a0a 100644
--- a/packages/replay/src/replay.ts
+++ b/packages/replay/src/replay.ts
@@ -368,7 +368,7 @@ export class ReplayContainer implements ReplayContainerInterface {
* Currently, this needs to be manually called (e.g. for tests). Sentry SDK
* does not support a teardown
*/
- public async stop(reason?: string): Promise {
+ public async stop({ forceFlush = false, reason }: { forceFlush?: boolean; reason?: string } = {}): Promise {
if (!this._isEnabled) {
return;
}
@@ -388,7 +388,7 @@ export class ReplayContainer implements ReplayContainerInterface {
this._debouncedFlush.cancel();
// See comment above re: `_isEnabled`, we "force" a flush, ignoring the
// `_isEnabled` state of the plugin since it was disabled above.
- if (this.recordingMode === 'session') {
+ if (forceFlush) {
await this._flush({ force: true });
}
@@ -777,7 +777,7 @@ export class ReplayContainer implements ReplayContainerInterface {
this.session = session;
if (!this.session.sampled) {
- void this.stop('session not refreshed');
+ void this.stop({ reason: 'session not refreshed' });
return false;
}
@@ -1067,6 +1067,15 @@ export class ReplayContainer implements ReplayContainerInterface {
// Note this empties the event buffer regardless of outcome of sending replay
const recordingData = await this.eventBuffer.finish();
+ const timestamp = Date.now();
+
+ // Check total duration again, to avoid sending outdated stuff
+ // We leave 30s wiggle room to accomodate late flushing etc.
+ // This _could_ happen when the browser is suspended during flushing, in which case we just want to stop
+ if (timestamp - this._context.initialTimestamp > this.timeouts.maxSessionLife + 30_000) {
+ throw new Error('Session is too long, not sending replay');
+ }
+
// NOTE: Copy values from instance members, as it's possible they could
// change before the flush finishes.
const replayId = this.session.id;
@@ -1082,7 +1091,7 @@ export class ReplayContainer implements ReplayContainerInterface {
eventContext,
session: this.session,
options: this.getOptions(),
- timestamp: Date.now(),
+ timestamp,
});
} catch (err) {
this._handleException(err);
@@ -1090,7 +1099,7 @@ export class ReplayContainer implements ReplayContainerInterface {
// This means we retried 3 times and all of them failed,
// or we ran into a problem we don't want to retry, like rate limiting.
// In this case, we want to completely stop the replay - otherwise, we may get inconsistent segments
- void this.stop('sendReplay');
+ void this.stop({ reason: 'sendReplay' });
const client = getCurrentHub().getClient();
@@ -1214,7 +1223,7 @@ export class ReplayContainer implements ReplayContainerInterface {
// Stop replay if over the mutation limit
if (overMutationLimit) {
- void this.stop('mutationLimit');
+ void this.stop({ reason: 'mutationLimit', forceFlush: this.recordingMode === 'session' });
return false;
}
diff --git a/packages/replay/src/types/replay.ts b/packages/replay/src/types/replay.ts
index 1fbf44aa1b95..00f363003979 100644
--- a/packages/replay/src/types/replay.ts
+++ b/packages/replay/src/types/replay.ts
@@ -451,7 +451,7 @@ export interface ReplayContainer {
getContext(): InternalEventContext;
initializeSampling(): void;
start(): void;
- stop(reason?: string): Promise;
+ stop(options?: { reason?: string; forceflush?: boolean }): Promise;
pause(): void;
resume(): void;
startRecording(): void;
diff --git a/packages/replay/src/util/addEvent.ts b/packages/replay/src/util/addEvent.ts
index d1e1d366a9e9..9024c3cbb6bd 100644
--- a/packages/replay/src/util/addEvent.ts
+++ b/packages/replay/src/util/addEvent.ts
@@ -4,6 +4,7 @@ import { logger } from '@sentry/utils';
import { EventBufferSizeExceededError } from '../eventBuffer/error';
import type { AddEventResult, RecordingEvent, ReplayContainer, ReplayFrameEvent, ReplayPluginOptions } from '../types';
+import { logInfo } from './log';
import { timestampToMs } from './timestamp';
function isCustomEvent(event: RecordingEvent): event is ReplayFrameEvent {
@@ -39,9 +40,21 @@ export async function addEvent(
return null;
}
+ // Throw out events that are +60min from the initial timestamp
+ if (timestampInMs > replay.getContext().initialTimestamp + replay.timeouts.maxSessionLife) {
+ logInfo(
+ `[Replay] Skipping event with timestamp ${timestampInMs} because it is after maxSessionLife`,
+ replay.getOptions()._experiments.traceInternals,
+ );
+ return null;
+ }
+
try {
if (isCheckout && replay.recordingMode === 'buffer') {
replay.eventBuffer.clear();
+ }
+
+ if (isCheckout) {
replay.eventBuffer.hasCheckout = true;
}
@@ -58,7 +71,7 @@ export async function addEvent(
const reason = error && error instanceof EventBufferSizeExceededError ? 'addEventSizeExceeded' : 'addEvent';
__DEBUG_BUILD__ && logger.error(error);
- await replay.stop(reason);
+ await replay.stop({ reason });
const client = getCurrentHub().getClient();
diff --git a/packages/replay/test/integration/errorSampleRate.test.ts b/packages/replay/test/integration/errorSampleRate.test.ts
index 7ed9c4774c71..777cb437f7e3 100644
--- a/packages/replay/test/integration/errorSampleRate.test.ts
+++ b/packages/replay/test/integration/errorSampleRate.test.ts
@@ -883,6 +883,50 @@ describe('Integration | errorSampleRate', () => {
expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0);
expect(replay.isEnabled()).toBe(false);
});
+
+ it('handles very long active buffer session', async () => {
+ const stepDuration = 10_000;
+ const steps = 5_000;
+
+ jest.setSystemTime(BASE_TIMESTAMP);
+
+ expect(replay).not.toHaveLastSentReplay();
+
+ let optionsEvent = createOptionsEvent(replay);
+
+ for (let i = 1; i <= steps; i++) {
+ jest.advanceTimersByTime(stepDuration);
+ optionsEvent = createOptionsEvent(replay);
+ mockRecord._emitter({ data: { step: i }, timestamp: BASE_TIMESTAMP + stepDuration * i, type: 2 }, true);
+ mockRecord._emitter({ data: { step: i }, timestamp: BASE_TIMESTAMP + stepDuration * i + 5, type: 3 });
+ }
+
+ expect(replay).not.toHaveLastSentReplay();
+
+ expect(replay.isEnabled()).toBe(true);
+ expect(replay.isPaused()).toBe(false);
+ expect(replay.recordingMode).toBe('buffer');
+
+ // Now capture an error
+ captureException(new Error('testing'));
+ await waitForBufferFlush();
+
+ expect(replay).toHaveLastSentReplay({
+ recordingData: JSON.stringify([
+ { data: { step: steps }, timestamp: BASE_TIMESTAMP + stepDuration * steps, type: 2 },
+ optionsEvent,
+ { data: { step: steps }, timestamp: BASE_TIMESTAMP + stepDuration * steps + 5, type: 3 },
+ ]),
+ replayEventPayload: expect.objectContaining({
+ replay_start_timestamp: (BASE_TIMESTAMP + stepDuration * steps) / 1000,
+ error_ids: [expect.any(String)],
+ trace_ids: [],
+ urls: ['http://localhost/'],
+ replay_id: expect.any(String),
+ }),
+ recordingPayloadHeader: { segment_id: 0 },
+ });
+ });
});
/**
diff --git a/packages/replay/test/integration/flush.test.ts b/packages/replay/test/integration/flush.test.ts
index 611a1043df1a..29ce2ba527fd 100644
--- a/packages/replay/test/integration/flush.test.ts
+++ b/packages/replay/test/integration/flush.test.ts
@@ -26,6 +26,7 @@ type MockFlush = jest.MockedFunction;
type MockRunFlush = jest.MockedFunction;
const prevLocation = WINDOW.location;
+const prevBrowserPerformanceTimeOrigin = SentryUtils.browserPerformanceTimeOrigin;
describe('Integration | flush', () => {
let domHandler: (args: any) => any;
@@ -91,6 +92,11 @@ describe('Integration | flush', () => {
}
mockEventBufferFinish = replay.eventBuffer?.finish as MockEventBufferFinish;
mockEventBufferFinish.mockClear();
+
+ Object.defineProperty(SentryUtils, 'browserPerformanceTimeOrigin', {
+ value: BASE_TIMESTAMP,
+ writable: true,
+ });
});
afterEach(async () => {
@@ -102,6 +108,10 @@ describe('Integration | flush', () => {
value: prevLocation,
writable: true,
});
+ Object.defineProperty(SentryUtils, 'browserPerformanceTimeOrigin', {
+ value: prevBrowserPerformanceTimeOrigin,
+ writable: true,
+ });
});
afterAll(() => {
@@ -224,6 +234,7 @@ describe('Integration | flush', () => {
// flush #5 @ t=25s - debounced flush calls `flush`
// 20s + `flushMinDelay` which is 5 seconds
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
+
expect(mockFlush).toHaveBeenCalledTimes(5);
expect(mockRunFlush).toHaveBeenCalledTimes(2);
expect(mockSendReplay).toHaveBeenLastCalledWith({
@@ -382,4 +393,94 @@ describe('Integration | flush', () => {
replay.getOptions()._experiments.traceInternals = false;
});
+
+ it('logs warning if adding event that is after maxSessionLife', async () => {
+ replay.getOptions()._experiments.traceInternals = true;
+
+ sessionStorage.clear();
+ clearSession(replay);
+ replay['_loadAndCheckSession']();
+ await new Promise(process.nextTick);
+ jest.setSystemTime(BASE_TIMESTAMP);
+
+ replay.eventBuffer!.clear();
+
+ // We do not care about this warning here
+ replay.eventBuffer!.hasCheckout = true;
+
+ // Add event that is too long after session start
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP + MAX_SESSION_LIFE + 100, type: 2 };
+ mockRecord._emitter(TEST_EVENT);
+
+ // no checkout!
+ await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
+
+ expect(mockFlush).toHaveBeenCalledTimes(1);
+ expect(mockSendReplay).toHaveBeenCalledTimes(1);
+
+ const replayData = mockSendReplay.mock.calls[0][0];
+
+ expect(JSON.parse(replayData.recordingData)).toEqual([
+ {
+ type: 5,
+ timestamp: BASE_TIMESTAMP,
+ data: {
+ tag: 'breadcrumb',
+ payload: {
+ timestamp: BASE_TIMESTAMP / 1000,
+ type: 'default',
+ category: 'console',
+ data: { logger: 'replay' },
+ level: 'info',
+ message: `[Replay] Skipping event with timestamp ${
+ BASE_TIMESTAMP + MAX_SESSION_LIFE + 100
+ } because it is after maxSessionLife`,
+ },
+ },
+ },
+ ]);
+
+ replay.getOptions()._experiments.traceInternals = false;
+ });
+
+ /**
+ * This tests the case where a flush happens in time,
+ * but something takes too long (e.g. because we are idle, ...)
+ * so by the time we actually send the replay it's too late.
+ * In this case, we want to stop the replay.
+ */
+ it('stops if flushing after maxSessionLife', async () => {
+ replay.timeouts.maxSessionLife = 100_000;
+
+ sessionStorage.clear();
+ clearSession(replay);
+ replay['_loadAndCheckSession']();
+ await new Promise(process.nextTick);
+ jest.setSystemTime(BASE_TIMESTAMP);
+
+ replay.eventBuffer!.clear();
+
+ // We do not care about this warning here
+ replay.eventBuffer!.hasCheckout = true;
+
+ // We want to simulate that flushing happens _way_ late
+ replay['_addPerformanceEntries'] = () => {
+ return new Promise(resolve => setTimeout(resolve, 140_000));
+ };
+
+ // Add event inside of session life timespan
+ const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP + 100, type: 2 };
+ mockRecord._emitter(TEST_EVENT);
+
+ await advanceTimers(160_000);
+
+ expect(mockFlush).toHaveBeenCalledTimes(1);
+ expect(mockSendReplay).toHaveBeenCalledTimes(0);
+ expect(replay.isEnabled()).toBe(false);
+
+ replay.timeouts.maxSessionLife = MAX_SESSION_LIFE;
+
+ // Start again for following tests
+ await replay.start();
+ });
});
diff --git a/packages/replay/test/unit/coreHandlers/handleClick.test.ts b/packages/replay/test/unit/coreHandlers/handleClick.test.ts
index f2980e60df69..ae52d2076293 100644
--- a/packages/replay/test/unit/coreHandlers/handleClick.test.ts
+++ b/packages/replay/test/unit/coreHandlers/handleClick.test.ts
@@ -71,6 +71,97 @@ describe('Unit | coreHandlers | handleClick', () => {
expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1);
});
+ test('it captures multiple clicks', async () => {
+ const replay = {
+ getCurrentRoute: () => 'test-route',
+ } as ReplayContainer;
+
+ const mockAddBreadcrumbEvent = jest.fn();
+
+ const detector = new ClickDetector(
+ replay,
+ {
+ threshold: 1_000,
+ timeout: 3_000,
+ scrollTimeout: 200,
+ ignoreSelector: '',
+ },
+ mockAddBreadcrumbEvent,
+ );
+
+ const breadcrumb1: Breadcrumb = {
+ timestamp: BASE_TIMESTAMP / 1000,
+ data: {
+ nodeId: 1,
+ },
+ };
+ const breadcrumb2: Breadcrumb = {
+ timestamp: (BASE_TIMESTAMP + 200) / 1000,
+ data: {
+ nodeId: 1,
+ },
+ };
+ const breadcrumb3: Breadcrumb = {
+ timestamp: (BASE_TIMESTAMP + 1200) / 1000,
+ data: {
+ nodeId: 1,
+ },
+ };
+ const node = document.createElement('button');
+ detector.handleClick(breadcrumb1, node);
+ detector.handleClick(breadcrumb2, node);
+ detector.handleClick(breadcrumb3, node);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ jest.advanceTimersByTime(1_000);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ jest.advanceTimersByTime(1_000);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0);
+
+ jest.advanceTimersByTime(1_000);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1);
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledWith(replay, {
+ category: 'ui.slowClickDetected',
+ type: 'default',
+ data: {
+ clickCount: 1,
+ endReason: 'timeout',
+ nodeId: 1,
+ route: 'test-route',
+ timeAfterClickMs: 3000,
+ url: 'http://localhost/',
+ },
+ message: undefined,
+ timestamp: BASE_TIMESTAMP / 1000,
+ });
+
+ jest.advanceTimersByTime(2_000);
+
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(2);
+ expect(mockAddBreadcrumbEvent).toHaveBeenLastCalledWith(replay, {
+ category: 'ui.slowClickDetected',
+ type: 'default',
+ data: {
+ clickCount: 1,
+ endReason: 'timeout',
+ nodeId: 1,
+ route: 'test-route',
+ timeAfterClickMs: 3000,
+ url: 'http://localhost/',
+ },
+ message: undefined,
+ timestamp: (BASE_TIMESTAMP + 1200) / 1000,
+ });
+
+ jest.advanceTimersByTime(5_000);
+ expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(2);
+ });
+
test('it captures clicks on different elements', async () => {
const replay = {
getCurrentRoute: () => 'test-route',
diff --git a/packages/tracing-internal/src/node/integrations/mongo.ts b/packages/tracing-internal/src/node/integrations/mongo.ts
index 637b7d124b33..c462ea4dcdf2 100644
--- a/packages/tracing-internal/src/node/integrations/mongo.ts
+++ b/packages/tracing-internal/src/node/integrations/mongo.ts
@@ -229,13 +229,14 @@ export class Mongo implements LazyLoadedIntegration {
args: unknown[],
): SpanContext {
const data: { [key: string]: string } = {
- collectionName: collection.collectionName,
- dbName: collection.dbName,
- namespace: collection.namespace,
'db.system': 'mongodb',
+ 'db.name': collection.dbName,
+ 'db.operation': operation,
+ 'db.mongodb.collection': collection.collectionName,
};
const spanContext: SpanContext = {
op: 'db',
+ // TODO v8: Use `${collection.collectionName}.${operation}`
description: operation,
data,
};
@@ -259,7 +260,7 @@ export class Mongo implements LazyLoadedIntegration {
data[signature[1]] = typeof reduce === 'string' ? reduce : reduce.name || '';
} else {
for (let i = 0; i < signature.length; i++) {
- data[signature[i]] = JSON.stringify(args[i]);
+ data[`db.mongodb.${signature[i]}`] = JSON.stringify(args[i]);
}
}
} catch (_oO) {
diff --git a/packages/tracing-internal/src/node/integrations/mysql.ts b/packages/tracing-internal/src/node/integrations/mysql.ts
index 8f85de1c9a8f..77ce6a66cd32 100644
--- a/packages/tracing-internal/src/node/integrations/mysql.ts
+++ b/packages/tracing-internal/src/node/integrations/mysql.ts
@@ -6,9 +6,18 @@ import type { LazyLoadedIntegration } from './lazy';
import { shouldDisableAutoInstrumentation } from './utils/node-utils';
interface MysqlConnection {
+ prototype: {
+ connect: () => void;
+ };
createQuery: () => void;
}
+interface MysqlConnectionConfig {
+ host: string;
+ port: number;
+ user: string;
+}
+
/** Tracing integration for node-mysql package */
export class Mysql implements LazyLoadedIntegration {
/**
@@ -48,6 +57,32 @@ export class Mysql implements LazyLoadedIntegration {
return;
}
+ let mySqlConfig: MysqlConnectionConfig | undefined = undefined;
+
+ try {
+ pkg.prototype.connect = new Proxy(pkg.prototype.connect, {
+ apply(wrappingTarget, thisArg: { config: MysqlConnectionConfig }, args) {
+ if (!mySqlConfig) {
+ mySqlConfig = thisArg.config;
+ }
+ return wrappingTarget.apply(thisArg, args);
+ },
+ });
+ } catch (e) {
+ __DEBUG_BUILD__ && logger.error('Mysql Integration was unable to instrument `mysql` config.');
+ }
+
+ function spanDataFromConfig(): Record {
+ if (!mySqlConfig) {
+ return {};
+ }
+ return {
+ 'server.address': mySqlConfig.host,
+ 'server.port': mySqlConfig.port,
+ 'db.user': mySqlConfig.user,
+ };
+ }
+
// The original function will have one of these signatures:
// function (callback) => void
// function (options, callback) => void
@@ -60,6 +95,7 @@ export class Mysql implements LazyLoadedIntegration {
description: typeof options === 'string' ? options : (options as { sql: string }).sql,
op: 'db',
data: {
+ ...spanDataFromConfig(),
'db.system': 'mysql',
},
});
diff --git a/packages/tracing-internal/src/node/integrations/postgres.ts b/packages/tracing-internal/src/node/integrations/postgres.ts
index 85c021c4f24b..f226c9264938 100644
--- a/packages/tracing-internal/src/node/integrations/postgres.ts
+++ b/packages/tracing-internal/src/node/integrations/postgres.ts
@@ -11,6 +11,13 @@ interface PgClient {
};
}
+interface PgClientThis {
+ database?: string;
+ host?: string;
+ port?: number;
+ user?: string;
+}
+
interface PgOptions {
usePgNative?: boolean;
}
@@ -74,15 +81,35 @@ export class Postgres implements LazyLoadedIntegration {
* function (pg.Cursor) => pg.Cursor
*/
fill(Client.prototype, 'query', function (orig: () => void | Promise) {
- return function (this: unknown, config: unknown, values: unknown, callback: unknown) {
+ return function (this: PgClientThis, config: unknown, values: unknown, callback: unknown) {
const scope = getCurrentHub().getScope();
const parentSpan = scope?.getSpan();
+
+ const data: Record = {
+ 'db.system': 'postgresql',
+ };
+
+ try {
+ if (this.database) {
+ data['db.name'] = this.database;
+ }
+ if (this.host) {
+ data['server.address'] = this.host;
+ }
+ if (this.port) {
+ data['server.port'] = this.port;
+ }
+ if (this.user) {
+ data['db.user'] = this.user;
+ }
+ } catch (e) {
+ // ignore
+ }
+
const span = parentSpan?.startChild({
description: typeof config === 'string' ? config : (config as { text: string }).text,
op: 'db',
- data: {
- 'db.system': 'postgresql',
- },
+ data,
});
if (typeof callback === 'function') {
diff --git a/packages/tracing/test/integrations/node/mongo.test.ts b/packages/tracing/test/integrations/node/mongo.test.ts
index 547b7708b55e..4e866f608cb8 100644
--- a/packages/tracing/test/integrations/node/mongo.test.ts
+++ b/packages/tracing/test/integrations/node/mongo.test.ts
@@ -74,10 +74,10 @@ describe('patchOperation()', () => {
expect(scope.getSpan).toBeCalled();
expect(parentSpan.startChild).toBeCalledWith({
data: {
- collectionName: 'mockedCollectionName',
- dbName: 'mockedDbName',
- doc: JSON.stringify(doc),
- namespace: 'mockedNamespace',
+ 'db.mongodb.collection': 'mockedCollectionName',
+ 'db.name': 'mockedDbName',
+ 'db.operation': 'insertOne',
+ 'db.mongodb.doc': JSON.stringify(doc),
'db.system': 'mongodb',
},
op: 'db',
@@ -93,10 +93,10 @@ describe('patchOperation()', () => {
expect(scope.getSpan).toBeCalled();
expect(parentSpan.startChild).toBeCalledWith({
data: {
- collectionName: 'mockedCollectionName',
- dbName: 'mockedDbName',
- doc: JSON.stringify(doc),
- namespace: 'mockedNamespace',
+ 'db.mongodb.collection': 'mockedCollectionName',
+ 'db.name': 'mockedDbName',
+ 'db.operation': 'insertOne',
+ 'db.mongodb.doc': JSON.stringify(doc),
'db.system': 'mongodb',
},
op: 'db',
@@ -110,9 +110,9 @@ describe('patchOperation()', () => {
expect(scope.getSpan).toBeCalled();
expect(parentSpan.startChild).toBeCalledWith({
data: {
- collectionName: 'mockedCollectionName',
- dbName: 'mockedDbName',
- namespace: 'mockedNamespace',
+ 'db.mongodb.collection': 'mockedCollectionName',
+ 'db.name': 'mockedDbName',
+ 'db.operation': 'initializeOrderedBulkOp',
'db.system': 'mongodb',
},
op: 'db',
diff --git a/packages/types/src/context.ts b/packages/types/src/context.ts
index 052d6f4a6523..110267284fc0 100644
--- a/packages/types/src/context.ts
+++ b/packages/types/src/context.ts
@@ -9,6 +9,7 @@ export interface Contexts extends Record {
culture?: CultureContext;
response?: ResponseContext;
trace?: TraceContext;
+ cloud_resource?: CloudResourceContext;
}
export interface AppContext extends Record {
@@ -93,3 +94,13 @@ export interface TraceContext extends Record {
tags?: { [key: string]: Primitive };
trace_id: string;
}
+
+export interface CloudResourceContext extends Record {
+ ['cloud.provider']?: string;
+ ['cloud.account.id']?: string;
+ ['cloud.region']?: string;
+ ['cloud.availability_zone']?: string;
+ ['cloud.platform']?: string;
+ ['host.id']?: string;
+ ['host.type']?: string;
+}
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 5ce2e1fe6ce5..5b9490045a02 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -9,7 +9,16 @@ export type {
} from './breadcrumb';
export type { Client } from './client';
export type { ClientReport, Outcome, EventDropReason } from './clientreport';
-export type { Context, Contexts, DeviceContext, OsContext, AppContext, CultureContext, TraceContext } from './context';
+export type {
+ Context,
+ Contexts,
+ DeviceContext,
+ OsContext,
+ AppContext,
+ CultureContext,
+ TraceContext,
+ CloudResourceContext,
+} from './context';
export type { DataCategory } from './datacategory';
export type { DsnComponents, DsnLike, DsnProtocol } from './dsn';
export type { DebugImage, DebugMeta } from './debugMeta';
diff --git a/packages/types/src/options.ts b/packages/types/src/options.ts
index 60d747136d90..74bcfad771f4 100644
--- a/packages/types/src/options.ts
+++ b/packages/types/src/options.ts
@@ -226,8 +226,8 @@ export interface ClientOptions