diff --git a/.size-limit.js b/.size-limit.js index 203e8cecb6ce..7ecd54ab92f4 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -47,7 +47,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '75.5 KB', + limit: '76 KB', }, { name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags', diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/subject.js new file mode 100644 index 000000000000..c6fbf085140a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/subject.js @@ -0,0 +1,15 @@ +const btn1 = document.getElementById('btn1'); +const btn2 = document.getElementById('btn2'); + +btn1.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({name: 'custom root span 1', op: 'custom'}, () => {}); + }); +}); + + +btn2.addEventListener('click', () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({name: 'custom root span 2', op: 'custom'}, () => {}); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/template.html new file mode 100644 index 000000000000..d5b66b29965d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/template.html @@ -0,0 +1,9 @@ + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/test.ts new file mode 100644 index 000000000000..ab2d8ae2c8af --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/custom-trace/test.ts @@ -0,0 +1,63 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; + +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; + +sentryTest('manually started custom traces are linked correctly in the chain', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadTraceContext = await sentryTest.step('Initial pageload', async () => { + const pageloadRequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); + await page.goto(url); + const pageloadRequest = envelopeRequestParser(await pageloadRequestPromise); + return pageloadRequest.contexts?.trace; + }); + + const customTrace1Context = await sentryTest.step('Custom trace', async () => { + const customTrace1RequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'custom'); + await page.locator('#btn1').click(); + const customTrace1Event = envelopeRequestParser(await customTrace1RequestPromise); + + const customTraceCtx = customTrace1Event.contexts?.trace; + + expect(customTraceCtx?.trace_id).not.toEqual(pageloadTraceContext?.trace_id); + expect(customTraceCtx?.links).toEqual([ + { + trace_id: pageloadTraceContext?.trace_id, + span_id: pageloadTraceContext?.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ]); + + return customTraceCtx; + }); + + await sentryTest.step('Navigation', async () => { + const navigation1RequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'navigation'); + await page.goto(`${url}#foo`); + const navigationEvent = envelopeRequestParser(await navigation1RequestPromise); + const navTraceContext = navigationEvent.contexts?.trace; + + expect(navTraceContext?.trace_id).not.toEqual(customTrace1Context?.trace_id); + expect(navTraceContext?.trace_id).not.toEqual(pageloadTraceContext?.trace_id); + + expect(navTraceContext?.links).toEqual([ + { + trace_id: customTrace1Context?.trace_id, + span_id: customTrace1Context?.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ]); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/default/test.ts new file mode 100644 index 000000000000..bf1d9f78e308 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/default/test.ts @@ -0,0 +1,92 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; + +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; + +sentryTest("navigation spans link back to previous trace's root span", async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadTraceContext = await sentryTest.step('Initial pageload', async () => { + const pageloadRequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); + await page.goto(url); + const pageloadRequest = envelopeRequestParser(await pageloadRequestPromise); + return pageloadRequest.contexts?.trace; + }); + + const navigation1TraceContext = await sentryTest.step('First navigation', async () => { + const navigation1RequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'navigation'); + await page.goto(`${url}#foo`); + const navigation1Request = envelopeRequestParser(await navigation1RequestPromise); + return navigation1Request.contexts?.trace; + }); + + const navigation2TraceContext = await sentryTest.step('Second navigation', async () => { + const navigation2RequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'navigation'); + await page.goto(`${url}#bar`); + const navigation2Request = envelopeRequestParser(await navigation2RequestPromise); + return navigation2Request.contexts?.trace; + }); + + const pageloadTraceId = pageloadTraceContext?.trace_id; + const navigation1TraceId = navigation1TraceContext?.trace_id; + const navigation2TraceId = navigation2TraceContext?.trace_id; + + expect(pageloadTraceContext?.links).toBeUndefined(); + + expect(navigation1TraceContext?.links).toEqual([ + { + trace_id: pageloadTraceId, + span_id: pageloadTraceContext?.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ]); + + expect(navigation2TraceContext?.links).toEqual([ + { + trace_id: navigation1TraceId, + span_id: navigation1TraceContext?.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ]); + + expect(pageloadTraceId).not.toEqual(navigation1TraceId); + expect(navigation1TraceId).not.toEqual(navigation2TraceId); + expect(pageloadTraceId).not.toEqual(navigation2TraceId); +}); + +sentryTest("doesn't link between hard page reloads by default", async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await sentryTest.step('First pageload', async () => { + const pageloadRequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); + await page.goto(url); + const pageload1Event = envelopeRequestParser(await pageloadRequestPromise); + + expect(pageload1Event.contexts?.trace).toBeDefined(); + expect(pageload1Event.contexts?.trace?.links).toBeUndefined(); + }); + + await sentryTest.step('Second pageload', async () => { + const pageload2RequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); + await page.reload(); + const pageload2Event = envelopeRequestParser(await pageload2RequestPromise); + + expect(pageload2Event.contexts?.trace).toBeDefined(); + expect(pageload2Event.contexts?.trace?.links).toBeUndefined(); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/init.js new file mode 100644 index 000000000000..1ce4125ee422 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/init.js new file mode 100644 index 000000000000..5d32c7af1e43 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + integrations: [Sentry.browserTracingIntegration({_experiments: {enableInteractions: true}})], +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/template.html new file mode 100644 index 000000000000..05c7fc4b2417 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/template.html @@ -0,0 +1,8 @@ + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/test.ts new file mode 100644 index 000000000000..ca2a184f6680 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/interaction-spans/test.ts @@ -0,0 +1,90 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; + +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; + +/* + This is quite peculiar behavior but it's a result of the route-based trace lifetime. + Once we shortened trace lifetime, this whole scenario will change as the interaction + spans will be their own trace. So most likely, we can replace this test with a new one + that covers the new default behavior. +*/ +sentryTest( + 'only the first root spans in the trace link back to the previous trace', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadTraceContext = await sentryTest.step('Initial pageload', async () => { + const pageloadRequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); + await page.goto(url); + + const pageloadEvent = envelopeRequestParser(await pageloadRequestPromise); + const traceContext = pageloadEvent.contexts?.trace; + + expect(traceContext).toBeDefined(); + expect(traceContext?.links).toBeUndefined(); + + return traceContext; + }); + + await sentryTest.step('Click Before navigation', async () => { + const interactionRequestPromise = waitForTransactionRequest(page, evt => { + return evt.contexts?.trace?.op === 'ui.action.click'; + }); + await page.click('#btn'); + + const interactionEvent = envelopeRequestParser(await interactionRequestPromise); + const interactionTraceContext = interactionEvent.contexts?.trace; + + // sanity check: route-based trace lifetime means the trace_id should be the same + expect(interactionTraceContext?.trace_id).toBe(pageloadTraceContext?.trace_id); + + // no links yet as previous root span belonged to same trace + expect(interactionTraceContext?.links).toBeUndefined(); + }); + + const navigationTraceContext = await sentryTest.step('Navigation', async () => { + const navigationRequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'navigation'); + await page.goto(`${url}#foo`); + const navigationEvent = envelopeRequestParser(await navigationRequestPromise); + + const traceContext = navigationEvent.contexts?.trace; + + expect(traceContext?.op).toBe('navigation'); + expect(traceContext?.links).toEqual([ + { + trace_id: pageloadTraceContext?.trace_id, + span_id: pageloadTraceContext?.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ]); + + expect(traceContext?.trace_id).not.toEqual(traceContext?.links![0].trace_id); + return traceContext; + }); + + await sentryTest.step('Click After navigation', async () => { + const interactionRequestPromise = waitForTransactionRequest(page, evt => { + return evt.contexts?.trace?.op === 'ui.action.click'; + }); + await page.click('#btn'); + const interactionEvent = envelopeRequestParser(await interactionRequestPromise); + + const interactionTraceContext = interactionEvent.contexts?.trace; + + // sanity check: route-based trace lifetime means the trace_id should be the same + expect(interactionTraceContext?.trace_id).toBe(navigationTraceContext?.trace_id); + + // since this is the second root span in the trace, it doesn't link back to the previous trace + expect(interactionTraceContext?.links).toBeUndefined(); + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/meta/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/meta/template.html new file mode 100644 index 000000000000..f8024594da10 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/meta/template.html @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/meta/test.ts new file mode 100644 index 000000000000..f5e2c7d613e0 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/meta/test.ts @@ -0,0 +1,55 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; + +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; + +sentryTest( + "links back to previous trace's local root span if continued from meta tags", + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const metaTagTraceId = '12345678901234567890123456789012'; + + const pageloadTraceContext = await sentryTest.step('Initial pageload', async () => { + const pageloadRequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); + await page.goto(url); + const pageloadRequest = envelopeRequestParser(await pageloadRequestPromise); + + const traceContext = pageloadRequest.contexts?.trace; + + // sanity check + expect(traceContext?.trace_id).toBe(metaTagTraceId); + + expect(traceContext?.links).toBeUndefined(); + + return traceContext; + }); + + const navigationTraceContext = await sentryTest.step('Navigation', async () => { + const navigationRequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'navigation'); + await page.goto(`${url}#foo`); + const navigationRequest = envelopeRequestParser(await navigationRequestPromise); + return navigationRequest.contexts?.trace; + }); + + const navigationTraceId = navigationTraceContext?.trace_id; + + expect(navigationTraceContext?.links).toEqual([ + { + trace_id: metaTagTraceId, + span_id: pageloadTraceContext?.span_id, + sampled: true, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ]); + + expect(navigationTraceId).not.toEqual(metaTagTraceId); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/negatively-sampled/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/negatively-sampled/init.js new file mode 100644 index 000000000000..6c884b0632c8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/negatively-sampled/init.js @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracesSampler: (ctx) => { + if (ctx.attributes['sentry.origin'] === 'auto.pageload.browser') { + return 0; + } + return 1; + } +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/negatively-sampled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/negatively-sampled/test.ts new file mode 100644 index 000000000000..2563b22ad701 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/negatively-sampled/test.ts @@ -0,0 +1,39 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; + +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; + +sentryTest('includes a span link to a previously negatively sampled span', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await sentryTest.step('Initial pageload', async () => { + // No event to check for an event here because this pageload span is sampled negatively! + await page.goto(url); + }); + + await sentryTest.step('Navigation', async () => { + const navigationRequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'navigation'); + await page.goto(`${url}#foo`); + const navigationEvent = envelopeRequestParser(await navigationRequestPromise); + const navigationTraceContext = navigationEvent.contexts?.trace; + + expect(navigationTraceContext?.op).toBe('navigation'); + expect(navigationTraceContext?.links).toEqual([ + { + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + sampled: false, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }, + ]); + + expect(navigationTraceContext?.trace_id).not.toEqual(navigationTraceContext?.links![0].trace_id); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/session-storage/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/session-storage/init.js new file mode 100644 index 000000000000..c346a6e054b3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/session-storage/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration({linkPreviousTrace: 'session-storage'})], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/session-storage/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/session-storage/test.ts new file mode 100644 index 000000000000..7489f528a0e8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/previous-trace-links/session-storage/test.ts @@ -0,0 +1,40 @@ +import { expect } from '@playwright/test'; +import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; + +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; + +sentryTest('adds link between hard page reloads when opting into sessionStorage', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageload1TraceContext = await sentryTest.step('First pageload', async () => { + const pageloadRequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); + await page.goto(url); + const pageload1Event = envelopeRequestParser(await pageloadRequestPromise); + const pageload1TraceContext = pageload1Event.contexts?.trace; + expect(pageload1TraceContext).toBeDefined(); + expect(pageload1TraceContext?.links).toBeUndefined(); + return pageload1TraceContext; + }); + + const pageload2Event = await sentryTest.step('Hard page reload', async () => { + const pageload2RequestPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); + await page.reload(); + return envelopeRequestParser(await pageload2RequestPromise); + }); + + expect(pageload2Event.contexts?.trace?.links).toEqual([ + { + trace_id: pageload1TraceContext?.trace_id, + span_id: pageload1TraceContext?.span_id, + sampled: true, + attributes: { [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace' }, + }, + ]); + + expect(pageload1TraceContext?.trace_id).not.toEqual(pageload2Event.contexts?.trace?.trace_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/create-next-app/tests/client-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-next-app/tests/client-transactions.test.ts index 8721a4d086bb..cbb2cae29265 100644 --- a/dev-packages/e2e-tests/test-applications/create-next-app/tests/client-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-next-app/tests/client-transactions.test.ts @@ -43,7 +43,7 @@ test('Sends a pageload transaction to Sentry', async ({ page }) => { ); }); -test('captures a navigation transcation to Sentry', async ({ page }) => { +test('captures a navigation transaction to Sentry', async ({ page }) => { const clientNavigationTxnEventPromise = waitForTransaction('create-next-app', txnEvent => { return txnEvent?.transaction === '/user/[id]'; }); @@ -53,7 +53,7 @@ test('captures a navigation transcation to Sentry', async ({ page }) => { // navigation to page const clickPromise = page.getByText('navigate').click(); - const [clientTxnEvent, serverTxnEvent, _1] = await Promise.all([clientNavigationTxnEventPromise, clickPromise]); + const [clientTxnEvent, serverTxnEvent] = await Promise.all([clientNavigationTxnEventPromise, clickPromise]); expect(clientTxnEvent).toEqual( expect.objectContaining({ @@ -76,6 +76,16 @@ test('captures a navigation transcation to Sentry', async ({ page }) => { 'sentry.sample_rate': 1, 'sentry.source': 'route', }), + links: [ + { + attributes: { + 'sentry.link.type': 'previous_trace', + }, + sampled: true, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + ], }, }, request: { diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts index c35d731915d6..ee0c507076fa 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts @@ -58,6 +58,16 @@ test('Captures a navigation transaction', async ({ page }) => { 'sentry.sample_rate': 1, 'sentry.source': 'route', }), + links: [ + { + attributes: { + 'sentry.link.type': 'previous_trace', + }, + sampled: true, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + ], op: 'navigation', span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: expect.stringMatching(/[a-f0-9]{32}/), @@ -145,6 +155,16 @@ test('Captures a lazy navigation transaction', async ({ page }) => { 'sentry.sample_rate': 1, 'sentry.source': 'route', }), + links: [ + { + attributes: { + 'sentry.link.type': 'previous_trace', + }, + sampled: true, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + ], op: 'navigation', span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: expect.stringMatching(/[a-f0-9]{32}/), diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts index 7abb269d15b0..920506838080 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts @@ -122,6 +122,16 @@ test('Captures a navigation transaction', async ({ page }) => { 'sentry.sample_rate': 1, 'sentry.source': 'route', }), + links: [ + { + attributes: { + 'sentry.link.type': 'previous_trace', + }, + sampled: true, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + ], op: 'navigation', span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: expect.stringMatching(/[a-f0-9]{32}/), diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts index 7c75c395c3af..61a583a7bf55 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts @@ -55,6 +55,16 @@ test('Captures a navigation transaction', async ({ page }) => { 'sentry.sample_rate': 1, 'sentry.source': 'route', }), + links: [ + { + attributes: { + 'sentry.link.type': 'previous_trace', + }, + sampled: true, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + ], op: 'navigation', span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: expect.stringMatching(/[a-f0-9]{32}/), diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index f34a37542d29..062b308527d6 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -24,6 +24,7 @@ import { getDynamicSamplingContextFromSpan, getIsolationScope, getLocationHref, + getRootSpan, logger, propagationContextFromHeaders, registerSpanErrorInstrumentation, @@ -35,6 +36,12 @@ import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; import { registerBackgroundTabDetection } from './backgroundtab'; import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './request'; +import type { PreviousTraceInfo } from './previousTrace'; +import { + addPreviousTraceSpanLink, + getPreviousTraceFromSessionStorage, + storePreviousTraceInSessionStorage, +} from './previousTrace'; export const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing'; @@ -142,6 +149,29 @@ export interface BrowserTracingOptions { */ enableHTTPTimings: boolean; + /** + * Link the currently started trace to a previous trace (e.g. a prior pageload, navigation or + * manually started span). When enabled, this option will allow you to navigate between traces + * in the Sentry UI. + * + * You can set this option to the following values: + * + * - `'in-memory'`: The previous trace data will be stored in memory. + * This is useful for single-page applications and enabled by default. + * + * - `'session-storage'`: The previous trace data will be stored in the `sessionStorage`. + * This is useful for multi-page applications or static sites but it means that the + * Sentry SDK writes to the browser's `sessionStorage`. + * + * - `'off'`: The previous trace data will not be stored or linked. + * + * Note that your `tracesSampleRate` or `tracesSampler` config significantly influences + * how often traces will be linked. + * + * @default 'in-memory' - see explanation above + */ + linkPreviousTrace: 'in-memory' | 'session-storage' | 'off'; + /** * _experiments allows the user to send options to define how this integration works. * @@ -175,6 +205,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { enableLongTask: true, enableLongAnimationFrame: true, enableInp: true, + linkPreviousTrace: 'in-memory', _experiments: {}, ...defaultRequestInstrumentationOptions, }; @@ -214,6 +245,7 @@ export const browserTracingIntegration = ((_options: Partial { + if (getRootSpan(span) !== span) { + return; + } + + if (linkPreviousTrace === 'session-storage') { + const updatedPreviousTraceInfo = addPreviousTraceSpanLink(getPreviousTraceFromSessionStorage(), span); + storePreviousTraceInSessionStorage(updatedPreviousTraceInfo); + } else { + inMemoryPreviousTraceInfo = addPreviousTraceSpanLink(inMemoryPreviousTraceInfo, span); + } + }); + } + if (WINDOW.location) { if (instrumentPageLoad) { const origin = browserPerformanceTimeOrigin(); diff --git a/packages/browser/src/tracing/previousTrace.ts b/packages/browser/src/tracing/previousTrace.ts new file mode 100644 index 000000000000..36b6936abf3d --- /dev/null +++ b/packages/browser/src/tracing/previousTrace.ts @@ -0,0 +1,103 @@ +import type { Span } from '@sentry/core'; +import { logger, SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, spanToJSON, type SpanContextData } from '@sentry/core'; +import { WINDOW } from '../exports'; +import { DEBUG_BUILD } from '../debug-build'; + +export interface PreviousTraceInfo { + /** + * Span context of the previous trace's local root span + */ + spanContext: SpanContextData; + + /** + * Timestamp in seconds when the previous trace was started + */ + startTimestamp: number; +} + +// 1h in seconds +export const PREVIOUS_TRACE_MAX_DURATION = 216_000; + +// session storage key +export const PREVIOUS_TRACE_KEY = 'sentry_previous_trace'; + +/** + * Adds a previous_trace span link to the passed span if the passed + * previousTraceInfo is still valid. + * + * @returns the updated previous trace info (based on the current span/trace) to + * be used on the next call + */ +export function addPreviousTraceSpanLink( + previousTraceInfo: PreviousTraceInfo | undefined, + span: Span, +): PreviousTraceInfo { + const spanJson = spanToJSON(span); + + if (!previousTraceInfo) { + return { + spanContext: span.spanContext(), + startTimestamp: spanJson.start_timestamp, + }; + } + + if (previousTraceInfo.spanContext.traceId === spanJson.trace_id) { + // This means, we're still in the same trace so let's not update the previous trace info + // or add a link to the current span. + // Once we move away from the long-lived, route-based trace model, we can remove this cases + return previousTraceInfo; + } + + // Only add the link if the startTimeStamp of the previous trace's root span is within + // PREVIOUS_TRACE_MAX_DURATION (1h) of the current root span's startTimestamp + // This is done to + // - avoid adding links to "stale" traces + // - enable more efficient querying for previous/next traces in Sentry + if (Date.now() / 1000 - previousTraceInfo.startTimestamp <= PREVIOUS_TRACE_MAX_DURATION) { + if (DEBUG_BUILD) { + logger.info( + `Adding previous_trace ${previousTraceInfo.spanContext} link to span ${{ + op: spanJson.op, + ...span.spanContext(), + }}`, + ); + } + + span.addLink({ + context: previousTraceInfo.spanContext, + attributes: { + [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + }, + }); + } + + return { + spanContext: span.spanContext(), + startTimestamp: spanToJSON(span).start_timestamp, + }; +} + +/** + * Stores @param previousTraceInfo in sessionStorage. + */ +export function storePreviousTraceInSessionStorage(previousTraceInfo: PreviousTraceInfo): void { + try { + WINDOW.sessionStorage.setItem(PREVIOUS_TRACE_KEY, JSON.stringify(previousTraceInfo)); + } catch (e) { + // Ignore potential errors (e.g. if sessionStorage is not available) + DEBUG_BUILD && logger.warn('Could not store previous trace in sessionStorage', e); + } +} + +/** + * Retrieves the previous trace from sessionStorage if available. + */ +export function getPreviousTraceFromSessionStorage(): PreviousTraceInfo | undefined { + try { + const previousTraceInfo = WINDOW.sessionStorage?.getItem(PREVIOUS_TRACE_KEY); + // @ts-expect-error - intentionally risking JSON.parse throwing when previousTraceInfo is null to save bundle size + return JSON.parse(previousTraceInfo); + } catch (e) { + return undefined; + } +} diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index 0b659332df99..ee43935cd531 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -203,6 +203,16 @@ describe('browserTracingIntegration', () => { [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', }, + links: [ + { + attributes: { + 'sentry.link.type': 'previous_trace', + }, + sampled: true, + span_id: span?.spanContext().spanId, + trace_id: span?.spanContext().traceId, + }, + ], span_id: expect.stringMatching(/[a-f0-9]{16}/), start_timestamp: expect.any(Number), trace_id: expect.stringMatching(/[a-f0-9]{32}/), @@ -230,6 +240,16 @@ describe('browserTracingIntegration', () => { [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', }, + links: [ + { + attributes: { + 'sentry.link.type': 'previous_trace', + }, + sampled: true, + span_id: span2?.spanContext().spanId, + trace_id: span2?.spanContext().traceId, + }, + ], span_id: expect.stringMatching(/[a-f0-9]{16}/), start_timestamp: expect.any(Number), trace_id: expect.stringMatching(/[a-f0-9]{32}/), @@ -483,6 +503,16 @@ describe('browserTracingIntegration', () => { [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', }, + links: [ + { + attributes: { + 'sentry.link.type': 'previous_trace', + }, + sampled: true, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + ], span_id: expect.stringMatching(/[a-f0-9]{16}/), start_timestamp: expect.any(Number), trace_id: expect.stringMatching(/[a-f0-9]{32}/), @@ -494,7 +524,13 @@ describe('browserTracingIntegration', () => { const client = new BrowserClient( getDefaultBrowserClientOptions({ tracesSampleRate: 1, - integrations: [browserTracingIntegration({ instrumentNavigation: false })], + integrations: [ + browserTracingIntegration({ + instrumentNavigation: false, + // disabling previous trace b/c not relevant for this test + linkPreviousTrace: 'off', + }), + ], }), ); setCurrentClient(client); @@ -992,6 +1028,75 @@ describe('browserTracingIntegration', () => { }); }); + describe('linkPreviousTrace', () => { + it('registers the previous trace listener on span start by default', () => { + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [browserTracingIntegration({ instrumentPageLoad: false, instrumentNavigation: false })], + }), + ); + setCurrentClient(client); + client.init(); + + const span1 = startInactiveSpan({ name: 'test span 1', forceTransaction: true }); + span1.end(); + const span1Json = spanToJSON(span1); + + expect(span1Json.links).toBeUndefined(); + + // ensure we start a new trace + getCurrentScope().setPropagationContext({ traceId: '123', sampleRand: 0.2 }); + + const span2 = startInactiveSpan({ name: 'test span 2', forceTransaction: true }); + span2.end(); + const spanJson2 = spanToJSON(span2); + + expect(spanJson2.links).toEqual([ + { + attributes: { + 'sentry.link.type': 'previous_trace', + }, + sampled: true, + span_id: span1Json.span_id, + trace_id: span1Json.trace_id, + }, + ]); + }); + + it("doesn't register the previous trace listener on span start if disabled", () => { + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [ + browserTracingIntegration({ + instrumentPageLoad: false, + instrumentNavigation: false, + linkPreviousTrace: 'off', + }), + ], + }), + ); + setCurrentClient(client); + client.init(); + + const span1 = startInactiveSpan({ name: 'test span 1', forceTransaction: true }); + span1.end(); + const span1Json = spanToJSON(span1); + + expect(span1Json.links).toBeUndefined(); + + // ensure we start a new trace + getCurrentScope().setPropagationContext({ traceId: '123', sampleRand: 0.2 }); + + const span2 = startInactiveSpan({ name: 'test span 2', forceTransaction: true }); + span2.end(); + const spanJson2 = spanToJSON(span2); + + expect(spanJson2.links).toBeUndefined(); + }); + }); + // TODO(lforst): I cannot manage to get this test to pass. /* it('heartbeatInterval can be a custom value', () => { diff --git a/packages/browser/test/tracing/previousTrace.test.ts b/packages/browser/test/tracing/previousTrace.test.ts new file mode 100644 index 000000000000..f5815cbedc68 --- /dev/null +++ b/packages/browser/test/tracing/previousTrace.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { PreviousTraceInfo } from '../../src/tracing/previousTrace'; +import { + addPreviousTraceSpanLink, + getPreviousTraceFromSessionStorage, + PREVIOUS_TRACE_KEY, + PREVIOUS_TRACE_MAX_DURATION, +} from '../../src/tracing/previousTrace'; +import { SentrySpan, spanToJSON, timestampInSeconds } from '@sentry/core'; +import { storePreviousTraceInSessionStorage } from '../../src/tracing/previousTrace'; + +describe('addPreviousTraceSpanLink', () => { + it(`adds a previous_trace span link to startSpanOptions if the previous trace was created within ${PREVIOUS_TRACE_MAX_DURATION}s`, () => { + const currentSpanStart = timestampInSeconds(); + + const previousTraceInfo: PreviousTraceInfo = { + spanContext: { + traceId: '123', + spanId: '456', + traceFlags: 1, + }, + // max time reached almost exactly + startTimestamp: currentSpanStart - PREVIOUS_TRACE_MAX_DURATION + 1, + }; + + const currentSpan = new SentrySpan({ + name: 'test', + startTimestamp: currentSpanStart, + parentSpanId: '789', + spanId: 'abc', + traceId: 'def', + sampled: true, + }); + + const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan); + + expect(spanToJSON(currentSpan).links).toEqual([ + { + attributes: { + 'sentry.link.type': 'previous_trace', + }, + trace_id: '123', + span_id: '456', + sampled: true, + }, + ]); + + expect(updatedPreviousTraceInfo).toEqual({ + spanContext: currentSpan.spanContext(), + startTimestamp: currentSpanStart, + }); + }); + + it(`doesn't add a previous_trace span link if the previous trace was created more than ${PREVIOUS_TRACE_MAX_DURATION}s ago`, () => { + const currentSpanStart = timestampInSeconds(); + + const previousTraceInfo: PreviousTraceInfo = { + spanContext: { + traceId: '123', + spanId: '456', + traceFlags: 0, + }, + startTimestamp: Date.now() / 1000 - PREVIOUS_TRACE_MAX_DURATION - 1, + }; + + const currentSpan = new SentrySpan({ + name: '/dashboard', + startTimestamp: currentSpanStart, + }); + + const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan); + + expect(spanToJSON(currentSpan).links).toBeUndefined(); + + // but still updates the previousTraceInfo to the current span + expect(updatedPreviousTraceInfo).toEqual({ + spanContext: currentSpan.spanContext(), + startTimestamp: currentSpanStart, + }); + }); + + it("doesn't overwrite previously existing span links", () => { + const previousTraceInfo: PreviousTraceInfo = { + spanContext: { + traceId: '123', + spanId: '456', + traceFlags: 1, + }, + startTimestamp: Date.now() / 1000, + }; + + const currentSpanStart = timestampInSeconds(); + + const currentSpan = new SentrySpan({ + name: '/dashboard', + links: [ + { + context: { + traceId: '789', + spanId: '101', + traceFlags: 1, + }, + attributes: { + someKey: 'someValue', + }, + }, + ], + startTimestamp: currentSpanStart, + }); + + const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan); + + expect(spanToJSON(currentSpan).links).toEqual([ + { + trace_id: '789', + span_id: '101', + sampled: true, + attributes: { + someKey: 'someValue', + }, + }, + { + attributes: { + 'sentry.link.type': 'previous_trace', + }, + trace_id: '123', + span_id: '456', + sampled: true, + }, + ]); + + expect(updatedPreviousTraceInfo).toEqual({ + spanContext: currentSpan.spanContext(), + startTimestamp: currentSpanStart, + }); + }); + + it("doesn't add a link and returns the current span's data as previous trace info, if previous trace info was undefined", () => { + const currentSpanStart = timestampInSeconds(); + const currentSpan = new SentrySpan({ name: 'test', startTimestamp: currentSpanStart }); + + const updatedPreviousTraceInfo = addPreviousTraceSpanLink(undefined, currentSpan); + + expect(spanToJSON(currentSpan).links).toBeUndefined(); + + expect(updatedPreviousTraceInfo).toEqual({ + spanContext: currentSpan.spanContext(), + startTimestamp: currentSpanStart, + }); + }); + + it("doesn't add a link and returns the unchanged previous trace info if the current span is part of the same trace", () => { + const currentSpanStart = timestampInSeconds(); + const currentSpan = new SentrySpan({ + name: 'test', + startTimestamp: currentSpanStart, + traceId: '123', + spanId: '456', + }); + + const previousTraceInfo: PreviousTraceInfo = { + spanContext: { + traceId: '123', + spanId: '456', + traceFlags: 1, + }, + startTimestamp: currentSpanStart - 1, + }; + + const updatedPreviousTraceInfo = addPreviousTraceSpanLink(previousTraceInfo, currentSpan); + + expect(spanToJSON(currentSpan).links).toBeUndefined(); + + expect(updatedPreviousTraceInfo).toBe(previousTraceInfo); + }); +}); + +describe('store and retrieve previous trace data via sessionStorage ', () => { + const storage: Record = {}; + const sessionStorageMock = { + setItem: vi.fn((key, value) => { + storage[key] = value; + }), + getItem: vi.fn(key => storage[key]), + }; + + beforeEach(() => { + vi.clearAllMocks(); + // @ts-expect-error - mock contains only necessary API + globalThis.sessionStorage = sessionStorageMock; + }); + + it('stores the previous trace info in sessionStorage', () => { + const previousTraceInfo: PreviousTraceInfo = { + spanContext: { + traceId: '123', + spanId: '456', + traceFlags: 1, + }, + startTimestamp: Date.now() / 1000, + }; + + storePreviousTraceInSessionStorage(previousTraceInfo); + expect(sessionStorageMock.setItem).toHaveBeenCalledWith(PREVIOUS_TRACE_KEY, JSON.stringify(previousTraceInfo)); + expect(getPreviousTraceFromSessionStorage()).toEqual(previousTraceInfo); + }); + + it("doesn't throw if accessing sessionStorage fails and returns undefined", () => { + // @ts-expect-error - this is fine + globalThis.sessionStorage = undefined; + + const previousTraceInfo: PreviousTraceInfo = { + spanContext: { + traceId: '123', + spanId: '456', + traceFlags: 1, + }, + startTimestamp: Date.now() / 1000, + }; + + expect(() => storePreviousTraceInSessionStorage(previousTraceInfo)).not.toThrow(); + expect(getPreviousTraceFromSessionStorage).not.toThrow(); + expect(getPreviousTraceFromSessionStorage()).toBeUndefined(); + }); +}); diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index dea57836d3bc..aa25b70f7304 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -57,3 +57,15 @@ export const SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE = 'cache.item_size'; /** TODO: Remove these once we update to latest semantic conventions */ export const SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD = 'http.request.method'; export const SEMANTIC_ATTRIBUTE_URL_FULL = 'url.full'; + +/** + * A span link attribute to mark the link as a special span link. + * + * Known values: + * - `previous_trace`: The span links to the frontend root span of the previous trace. + * - `next_trace`: The span links to the frontend root span of the next trace. (Not set by the SDK) + * + * Other values may be set as appropriate. + * @see https://develop.sentry.dev/sdk/telemetry/traces/span-links/#link-types + */ +export const SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE = 'sentry.link.type';