Browse Source

feat(insights): Formatting for MongoDB queries (#77215)

Adds pretty printing for MongoDB queries in the following locations:

### Queries landing page: Tooltip hover

![image](https://github.com/user-attachments/assets/aca367d3-be09-42a4-a2f1-ea1df08d6809)

### Query summary page:

![image](https://github.com/user-attachments/assets/ce4e705b-1e32-47e9-85ff-e20dbec4604f)
Ash 6 months ago
parent
commit
bfd5897dda

+ 6 - 2
static/app/views/insights/browser/resources/components/tables/resourceSummaryTable.tsx

@@ -27,7 +27,11 @@ import {
   DataTitles,
   getThroughputTitle,
 } from 'sentry/views/insights/common/views/spans/types';
-import {SpanIndexedField, SpanMetricsField} from 'sentry/views/insights/types';
+import {
+  ModuleName,
+  SpanIndexedField,
+  SpanMetricsField,
+} from 'sentry/views/insights/types';
 
 const {
   RESOURCE_RENDER_BLOCKING_STATUS,
@@ -126,7 +130,7 @@ function ResourceSummaryTable() {
                 <TitleWrapper>{t('Example')}</TitleWrapper>
                 <FullSpanDescription
                   group={groupId}
-                  language="http"
+                  moduleName={ModuleName.RESOURCE}
                   filters={{
                     [SpanIndexedField.RESOURCE_RENDER_BLOCKING_STATUS]:
                       row[RESOURCE_RENDER_BLOCKING_STATUS],

+ 138 - 0
static/app/views/insights/common/components/fullSpanDescription.spec.tsx

@@ -0,0 +1,138 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+import {ProjectFixture} from 'sentry-fixture/project';
+
+import {render, screen, waitForElementToBeRemoved} from 'sentry-test/reactTestingLibrary';
+
+import {EntryType} from 'sentry/types/event';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import {FullSpanDescription} from 'sentry/views/insights/common/components/fullSpanDescription';
+import {ModuleName} from 'sentry/views/insights/types';
+
+jest.mock('sentry/utils/usePageFilters');
+
+describe('FullSpanDescription', function () {
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  const organization = OrganizationFixture();
+
+  const project = ProjectFixture();
+
+  jest.mocked(usePageFilters).mockReturnValue({
+    isReady: true,
+    desyncedFilters: new Set(),
+    pinnedFilters: new Set(),
+    shouldPersist: true,
+    selection: {
+      datetime: {
+        period: '10d',
+        start: null,
+        end: null,
+        utc: false,
+      },
+      environments: [],
+      projects: [],
+    },
+  });
+
+  const groupId = '2ed2abf6ce7e3577';
+  const spanId = 'abfed2aabf';
+  const eventId = '65c7d8647b8a76ef8f4c05d41deb7860';
+
+  it('uses the correct code formatting for SQL queries', async function () {
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events/`,
+      body: {
+        data: [
+          {
+            'transaction.id': eventId,
+            project: project.slug,
+            span_id: spanId,
+          },
+        ],
+      },
+    });
+
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events/${project.slug}:${eventId}/`,
+      body: {
+        id: eventId,
+        entries: [
+          {
+            type: EntryType.SPANS,
+            data: [
+              {
+                span_id: spanId,
+                description: 'SELECT users FROM my_table LIMIT 1;',
+              },
+            ],
+          },
+        ],
+      },
+    });
+
+    render(
+      <FullSpanDescription
+        group={groupId}
+        shortDescription={'SELECT users FRO*'}
+        moduleName={ModuleName.DB}
+      />,
+      {organization}
+    );
+
+    await waitForElementToBeRemoved(() => screen.getByTestId('loading-indicator'));
+
+    const queryCodeSnippet = await screen.findByText(
+      /select users from my_table limit 1;/i
+    );
+    expect(queryCodeSnippet).toBeInTheDocument();
+    expect(queryCodeSnippet).toHaveClass('language-sql');
+  });
+
+  it('uses the correct code formatting for MongoDB queries', async function () {
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events/`,
+      body: {
+        data: [
+          {
+            'transaction.id': eventId,
+            project: project.slug,
+            span_id: spanId,
+          },
+        ],
+      },
+    });
+
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events/${project.slug}:${eventId}/`,
+      body: {
+        id: eventId,
+        entries: [
+          {
+            type: EntryType.SPANS,
+            data: [
+              {
+                span_id: spanId,
+                description: `{"insert": "my_cool_collection😎", "a": {}}`,
+                data: {'db.system': 'mongodb'},
+              },
+            ],
+          },
+        ],
+      },
+    });
+
+    render(<FullSpanDescription group={groupId} moduleName={ModuleName.DB} />, {
+      organization,
+    });
+
+    await waitForElementToBeRemoved(() => screen.getByTestId('loading-indicator'));
+
+    const queryCodeSnippet = screen.getByText(
+      /\{ "insert": "my_cool_collection😎", "a": \{\} \}/i
+    );
+    expect(queryCodeSnippet).toBeInTheDocument();
+    expect(queryCodeSnippet).toHaveClass('language-json');
+  });
+});

+ 37 - 13
static/app/views/insights/common/components/fullSpanDescription.tsx

@@ -6,17 +6,36 @@ import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {space} from 'sentry/styles/space';
 import {SQLishFormatter} from 'sentry/utils/sqlish/SQLishFormatter';
 import {useFullSpanFromTrace} from 'sentry/views/insights/common/queries/useFullSpanFromTrace';
+import {ModuleName} from 'sentry/views/insights/types';
 
 const formatter = new SQLishFormatter();
 
-export function FullSpanDescription({group, shortDescription, language, filters}: Props) {
+const INDEXED_SPAN_SORT = {
+  field: 'span.self_time',
+  kind: 'desc' as const,
+};
+
+interface Props {
+  moduleName: ModuleName;
+  filters?: Record<string, string>;
+  group?: string;
+  shortDescription?: string;
+}
+
+export function FullSpanDescription({
+  group,
+  shortDescription,
+  filters,
+  moduleName,
+}: Props) {
   const {
     data: fullSpan,
     isLoading,
     isFetching,
-  } = useFullSpanFromTrace(group, undefined, Boolean(group), filters);
+  } = useFullSpanFromTrace(group, [INDEXED_SPAN_SORT], Boolean(group), filters);
 
   const description = fullSpan?.description ?? shortDescription;
+  const system = fullSpan?.data?.['db.system'];
 
   if (isLoading && isFetching) {
     return (
@@ -30,28 +49,33 @@ export function FullSpanDescription({group, shortDescription, language, filters}
     return null;
   }
 
-  if (language === 'sql') {
+  if (moduleName === ModuleName.DB) {
+    if (system === 'mongodb') {
+      return (
+        <CodeSnippet language="json">
+          {JSON.stringify(
+            JSON.parse(fullSpan?.sentry_tags?.description ?? fullSpan?.description ?? ''),
+            null,
+            4
+          ) ?? ''}
+        </CodeSnippet>
+      );
+    }
+
     return (
-      <CodeSnippet language={language}>
+      <CodeSnippet language="sql">
         {formatter.toString(description, {maxLineLength: LINE_LENGTH})}
       </CodeSnippet>
     );
   }
 
-  if (language) {
-    return <CodeSnippet language={language}>{description}</CodeSnippet>;
+  if (moduleName === ModuleName.RESOURCE) {
+    return <CodeSnippet language="http">{description}</CodeSnippet>;
   }
 
   return <Fragment>{description}</Fragment>;
 }
 
-interface Props {
-  filters?: Record<string, string>;
-  group?: string;
-  language?: 'sql' | 'http';
-  shortDescription?: string;
-}
-
 const LINE_LENGTH = 60;
 
 const PaddedSpinner = styled('div')`

+ 59 - 0
static/app/views/insights/common/components/spanDescription.spec.tsx

@@ -11,6 +11,10 @@ import {DatabaseSpanDescription} from 'sentry/views/insights/common/components/s
 jest.mock('sentry/utils/usePageFilters');
 
 describe('DatabaseSpanDescription', function () {
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
   const organization = OrganizationFixture();
 
   const project = ProjectFixture();
@@ -155,4 +159,59 @@ describe('DatabaseSpanDescription', function () {
       screen.getByText(textWithMarkupMatcher('/app/views/users.py at line 78'))
     ).toBeInTheDocument();
   });
+
+  it('correctly formats and displays MongoDB queries', async function () {
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events/`,
+      body: {
+        data: [
+          {
+            'transaction.id': eventId,
+            project: project.slug,
+            span_id: spanId,
+          },
+        ],
+      },
+    });
+
+    const sampleMongoDBQuery = `{"a": "?", "insert": "documents"}`;
+
+    MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events/${project.slug}:${eventId}/`,
+      body: {
+        id: eventId,
+        entries: [
+          {
+            type: EntryType.SPANS,
+            data: [
+              {
+                span_id: spanId,
+                description: sampleMongoDBQuery,
+                data: {
+                  'db.system': 'mongodb',
+                },
+              },
+            ],
+          },
+        ],
+      },
+    });
+
+    render(
+      <DatabaseSpanDescription
+        groupId={groupId}
+        preliminaryDescription={sampleMongoDBQuery}
+      />,
+      {organization}
+    );
+
+    await waitForElementToBeRemoved(() => screen.getByTestId('loading-indicator'));
+
+    // expect(await screen.findBy).toBeInTheDocument();
+    const mongoQuerySnippet = await screen.findByText(
+      /\{ "a": "\?", "insert": "documents" \}/i
+    );
+    expect(mongoQuerySnippet).toBeInTheDocument();
+    expect(mongoQuerySnippet).toHaveClass('language-json');
+  });
 });

+ 41 - 7
static/app/views/insights/common/components/spanDescription.tsx

@@ -58,22 +58,32 @@ export function DatabaseSpanDescription({
     Boolean(indexedSpan)
   );
 
-  const rawDescription =
-    rawSpan?.description || indexedSpan?.['span.description'] || preliminaryDescription;
+  const system = rawSpan?.data?.['db.system'];
+
+  const formattedDescription = useMemo(() => {
+    const rawDescription =
+      rawSpan?.description || indexedSpan?.['span.description'] || preliminaryDescription;
+
+    if (preliminaryDescription && isNoSQLQuery(preliminaryDescription)) {
+      return formatJsonQuery(preliminaryDescription);
+    }
+
+    if (rawSpan?.description && isNoSQLQuery(rawSpan?.description)) {
+      return formatJsonQuery(rawSpan?.description);
+    }
 
-  const formatterDescription = useMemo(() => {
     return formatter.toString(rawDescription ?? '');
-  }, [rawDescription]);
+  }, [preliminaryDescription, rawSpan, indexedSpan]);
 
   return (
     <Frame>
-      {areIndexedSpansLoading ? (
+      {areIndexedSpansLoading || !preliminaryDescription ? (
         <WithPadding>
           <LoadingIndicator mini />
         </WithPadding>
       ) : (
-        <CodeSnippet language="sql" isRounded={false}>
-          {formatterDescription}
+        <CodeSnippet language={system === 'mongodb' ? 'json' : 'sql'} isRounded={false}>
+          {formattedDescription ?? ''}
         </CodeSnippet>
       )}
 
@@ -98,6 +108,30 @@ export function DatabaseSpanDescription({
   );
 }
 
+// TODO: We should transform the data a bit for mongodb queries.
+// For example, it would be better if we display the operation on the collection as the
+// first key value pair in the JSON, since this is not guaranteed by the backend
+export function formatJsonQuery(queryString: string) {
+  try {
+    return JSON.stringify(JSON.parse(queryString), null, 4);
+  } catch (error) {
+    throw Error(`Failed to parse JSON: ${queryString}`);
+  }
+}
+
+function isNoSQLQuery(queryString?: string, system?: string) {
+  if (system && system === 'mongodb') {
+    return true;
+  }
+
+  // If the system isn't provided, we can at least infer that it is valid JSON if it is enclosed in parentheses
+  if (queryString?.startsWith('{') && queryString.endsWith('}')) {
+    return true;
+  }
+
+  return false;
+}
+
 const INDEXED_SPAN_SORT = {
   field: 'span.self_time',
   kind: 'desc' as const,

+ 2 - 2
static/app/views/insights/common/components/tableCells/spanDescriptionCell.tsx

@@ -61,7 +61,7 @@ export function SpanDescriptionCell({
           <FullSpanDescription
             group={group}
             shortDescription={rawDescription}
-            language="sql"
+            moduleName={moduleName}
           />
         }
       >
@@ -80,7 +80,7 @@ export function SpanDescriptionCell({
             <FullSpanDescription
               group={group}
               shortDescription={rawDescription}
-              language="http"
+              moduleName={moduleName}
               filters={spanOp ? {[SPAN_OP]: spanOp} : undefined}
             />
           </Fragment>

+ 1 - 0
static/app/views/insights/database/views/databaseSpanSummaryPage.spec.tsx

@@ -65,6 +65,7 @@ describe('DatabaseSpanSummaryPage', function () {
         data: [
           {
             'span.op': 'db',
+            'span.description': 'SELECT thing FROM my_table;',
           },
         ],
       },