Repository: getsentry/sentry
Pull request: ref(devtools): Return current transaction name and search term
This gets the current transaction name from an instance of the JS SDK, and turns it into an issue search-query.
We get the transaction name from the window's Sentry SDK instead of importing Sentry. The way to access the scope of the SDK changed in v8.6.0, so there's a new way and a legacy way to access the scope. This ensures that the toolbar will work with SDK v7+. 
We then change the transaction name into an issue searchable query. The transaction name could have an id, which could be in a generalized format. This PR replaces IDs that are only integers, and ids with the format `:id`, `<id>`, `{id}`, and `[id]` with `*` so the issue search doesn't only apply to pages with that specific id. In the future, we could expand the regex to replace UUIDs too.
If the scope is needed for other use cases, we could split that into a separate file, but for now it's easier transfer this into the dev toolbar repo if everything is in one file.

Before:
<img width="1485" alt="image" src="https://github.com/user-attachments/assets/474a4ae6-7c62-48b6-abc2-6ba4897b92ea">

After: 
<img width="1485" alt="image" src="https://github.com/user-attachments/assets/e37c66fc-09c3-4feb-95ed-e19d3dcd619b">


Closes https://github.com/getsentry/sentry-toolbar/issues/72

Problem statement:
[DevToolbar] Create a function to return the current transaction name from an instance of the js sdk
I want to have a function that accepts an instance of the sentry SDK and reads off the name of the current transaction.

We're going to convert that transaction name into an issue search-query, so we can find all issues related to that transaction name. So if the sdk says a transaction name is `/issues/:groupid` we should convert it to `/issues/*`. The format of the transaction name might be different depending on the frontend framework, ie: it could be `/issues/[groupid]` and we still want to convert it to `/issues/*`

The user should pass in their SDK instance, with the framework-specific extensions added (ie: `@sentry/react` or `@sentry/vue`) but the instance might not have a DSN passed in. This is ok, we should do something similar to what Spotlight does and hook into `beforeSend` events so that we get the transaction name, but no events are sent to the server.

Also! Extra points if there's a callback or event that can alert other code that the transaction name has changed.


Examples for what this could look like:
```
// sentry init code somewhere....
import * as Sentry from '@sentry/react';
Sentry.init({ dsn: '' /* no dsn so no data is sent */ });

// inside react:
const transactionName = useTransactionName(Sentry.getClient());
const search = `url:${toSearchTerm(transactionName)}`;

function toSearchTerm(txnName: string): string {
  return txnName.replace(':groupid', '*'); // this kind of thing, except we don't statically know the name of the parameter, so we have to look for segments that start with `:` or are wrapped with `[]` or wrapped with `<>`.
}
```

Hints from issue discussion:
cc @c298lee 
Here is some code from Spotlight which does detection of the SDK and gets a reference automatically: https://github.com/getsentry/spotlight/blob/e41f7c3efaef589029bd947ff7c49eec080d9ab4/packages/overlay/src/integrations/sentry/index.ts#L260-L280

we should borrow that to start!
We also want to convert non parametrized URLs to a search term, maybe search for hashes or IDs and replace them with `*`
related to https://github.com/getsentry/sentry-toolbar/issues/62

Commit message: remove comment

Patch to review:
diff --git a/static/app/components/devtoolbar/hooks/useCurrentTransactionName.test.tsx b/static/app/components/devtoolbar/hooks/useCurrentTransactionName.test.tsx
new file mode 100644
index 00000000000000..de30c15fe60b8a
--- /dev/null
+++ b/static/app/components/devtoolbar/hooks/useCurrentTransactionName.test.tsx
@@ -0,0 +1,26 @@
+import {toSearchTerm} from 'sentry/components/devtoolbar/hooks/useCurrentTransactionName';
+
+describe('getSearchTerm', () => {
+  it.each([
+    {
+      transactionName: '//alerts/rules/details/:ruleId/',
+      searchTerm: '/alerts/rules/details/*/',
+    },
+    {transactionName: '/pokemon/[pokemonName]', searchTerm: '/pokemon/*/'},
+    {transactionName: '/replays/<id>/details/', searchTerm: '/replays/*/details/'},
+    {
+      transactionName: '/param/{id}/param2/key:value/',
+      searchTerm: '/param/*/param2/key:value/',
+    },
+    {transactionName: '/issues/4489703641/', searchTerm: '/issues/*/'},
+    {
+      transactionName: '/all/:id1/:id2/param',
+      searchTerm: '/all/*/*/param/',
+    },
+  ])(
+    'should get the correct search term from the transaction name',
+    ({transactionName, searchTerm}) => {
+      expect(toSearchTerm(transactionName)).toStrictEqual(searchTerm);
+    }
+  );
+});
diff --git a/static/app/components/devtoolbar/hooks/useCurrentTransactionName.tsx b/static/app/components/devtoolbar/hooks/useCurrentTransactionName.tsx
index fb5aaefb9ed723..42698e471ed075 100644
--- a/static/app/components/devtoolbar/hooks/useCurrentTransactionName.tsx
+++ b/static/app/components/devtoolbar/hooks/useCurrentTransactionName.tsx
@@ -1,8 +1,88 @@
-import * as Sentry from '@sentry/react';
+import type {Scope} from '@sentry/types';
+
+type V8Carrier = {
+  stack: {
+    getScope?: () => Scope;
+  };
+};
+
+type LegacyCarrier = {
+  /** pre-v8 way of accessing scope (v7 and earlier) */
+  hub?: {
+    getScope?: () => Scope;
+  };
+};
+
+type VersionedCarrier = {version: string} & Record<Exclude<string, 'version'>, V8Carrier>;
+
+type WindowWithSentry = Window & {
+  __SENTRY__?: LegacyCarrier & VersionedCarrier;
+};
 
 export default function useCurrentTransactionName() {
-  const scope = Sentry.getCurrentScope();
+  const scope = getScope();
   const scopeData = scope.getScopeData();
-  const transactionName = `/${scopeData.transactionName}/`.replaceAll('//', '/');
-  return transactionName;
+
+  const search = toSearchTerm(scopeData.transactionName);
+
+  return search;
+}
+
+function getScope() {
+  const sentryCarrier = (window as WindowWithSentry).__SENTRY__;
+  const sentryClient = sentryCarrier && getSentryScope(sentryCarrier);
+
+  if (!sentryClient) {
+    // TODO: find a way to get scope on v7 of the Sentry SDK
+    throw Error(
+      "Couldn't find a Sentry SDK client. Make sure you're using a Sentry SDK with version 7.x or 8.x"
+    );
+  }
+
+  return sentryClient;
+}
+
+/**
+ * Accesses the `window.__SENTRY__` carrier object and tries to get the Sentry client
+ * from it. This function supports all carrier object structures from v7 to all versions
+ * of v8.
+ */
+function getSentryScope(
+  sentryCarrier: LegacyCarrier & VersionedCarrier
+): Scope | undefined {
+  // 8.6.0+ way to get the scope
+  if (sentryCarrier.version) {
+    const versionedCarrier = sentryCarrier[sentryCarrier.version];
+    const scope =
+      typeof versionedCarrier?.stack?.getScope === 'function'
+        ? versionedCarrier?.stack?.getScope?.()
+        : undefined;
+    return scope;
+  }
+
+  // pre-8.6.0 (+v7) way to get the scope
+  if (sentryCarrier.hub) {
+    const hub = sentryCarrier.hub;
+    if (typeof hub.getScope === 'function') {
+      return hub.getScope();
+    }
+  }
+
+  return undefined;
+}
+
+export function toSearchTerm(transaction) {
+  // finds parameterized parts of transaction name: /:param, /[param], /{param}, /<param>,
+  const parameterizedRegex =
+    /([\/])(([:]([^\/]*))|([\[]([^\/]*)[\]])|([{]([^\/]*)[}])|([<]([^\/]*)[>]))/g;
+
+  transaction = transaction.replaceAll(parameterizedRegex, '/*');
+
+  // finds nonparameterized parts of transaction: /12345/
+  const nonparameterizedRegex = /([\/])([0-9]+)/g;
+  transaction = transaction.replaceAll(nonparameterizedRegex, '/*');
+
+  // Step 3: Join the array back into a string with '/'
+  const searchTerm = `/${transaction}/`.replaceAll(/\/+/g, '/');
+  return searchTerm;
 }


Code review comments:
diff_hunk: @@ -1,8 +1,88 @@
-import * as Sentry from '@sentry/react';
+import type {Scope} from '@sentry/types';
+
+type V8Carrier = {
+  stack: {
+    getScope?: () => Scope;
+  };
+};
+
+type LegacyCarrier = {
+  /** pre-v8 way of accessing scope (v7 and earlier) */
+  hub?: {
+    getScope?: () => Scope;
+  };
+};
+
+type VersionedCarrier = {version: string} & Record<Exclude<string, 'version'>, V8Carrier>;
+
+type WindowWithSentry = Window & {
+  __SENTRY__?: LegacyCarrier & VersionedCarrier;
+};
 
 export default function useCurrentTransactionName() {
-  const scope = Sentry.getCurrentScope();
+  const scope = getScope();
   const scopeData = scope.getScopeData();
-  const transactionName = `/${scopeData.transactionName}/`.replaceAll('//', '/');
-  return transactionName;
+
+  const search = toSearchTerm(scopeData.transactionName);
+
+  return search;
+}
+
+function getScope() {
+  const sentryCarrier = (window as WindowWithSentry).__SENTRY__;
+  const sentryClient = sentryCarrier && getSentryScope(sentryCarrier);
+
+  if (!sentryClient) {
+    // TODO: find a way to get scope on v7 of the Sentry SDK
path: static/app/components/devtoolbar/hooks/useCurrentTransactionName.tsx
text: @user1:
Is this still TODO?

@author:
oops forgot to update it