Browse Source

fix(insights): Handle truncated MongoDB queries (#77648)

DB span descriptions in the metrics dataset can be truncated if they are
too long. This results in broken unparseable JSON which messes up the
hover tooltip content, and the formatting of the query in the row.

This PR introduces the `jsonrepair` module to handle truncated JSON by
repairing it into something the parser can handle and format
appropriately.


### Example:
This is a truncated query, after this PR, it is correctly formatted with
spacing and the last entry gets assigned a value

![image](https://github.com/user-attachments/assets/33e15403-1ba1-48fc-9252-110a5b1583ec)

The tooltip also gets formatted correctly:

![image](https://github.com/user-attachments/assets/7dd62209-f1ce-453c-99c8-e0bc95566c01)

In a followup PR I may include some sort of indication to the user that
the query is truncated, since it is not obvious from looking at it here
Ash 5 months ago
parent
commit
eaeb26fea5

+ 1 - 0
package.json

@@ -128,6 +128,7 @@
     "jest-fetch-mock": "^3.0.3",
     "js-beautify": "^1.15.1",
     "js-cookie": "3.0.1",
+    "jsonrepair": "^3.8.0",
     "less": "^4.1.3",
     "less-loader": "^12.2.0",
     "lightningcss": "^1.26.0",

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

@@ -135,4 +135,51 @@ describe('FullSpanDescription', function () {
     expect(queryCodeSnippet).toBeInTheDocument();
     expect(queryCodeSnippet).toHaveClass('language-json');
   });
+
+  it('successfully handles truncated 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": {}, "uh_oh":"the_query_is_truncated", "ohno*`,
+                data: {'db.system': 'mongodb'},
+              },
+            ],
+          },
+        ],
+      },
+    });
+
+    render(<FullSpanDescription group={groupId} moduleName={ModuleName.DB} />, {
+      organization,
+    });
+
+    await waitForElementToBeRemoved(() => screen.getByTestId('loading-indicator'));
+
+    // The last truncated entry will have a null value assigned and the JSON document is properly closed
+    const queryCodeSnippet = screen.getByText(
+      /\{ "insert": "my_cool_collection😎", "a": \{\}, "uh_oh": "the_query_is_truncated", "ohno\*": null \}/i
+    );
+    expect(queryCodeSnippet).toBeInTheDocument();
+    expect(queryCodeSnippet).toHaveClass('language-json');
+  });
 });

+ 5 - 6
static/app/views/insights/common/components/fullSpanDescription.tsx

@@ -6,10 +6,7 @@ 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 {
-  isValidJson,
-  prettyPrintJsonString,
-} from 'sentry/views/insights/database/utils/jsonUtils';
+import {prettyPrintJsonString} from 'sentry/views/insights/database/utils/jsonUtils';
 import {ModuleName} from 'sentry/views/insights/types';
 
 const formatter = new SQLishFormatter();
@@ -57,10 +54,12 @@ export function FullSpanDescription({
     if (system === 'mongodb') {
       let stringifiedQuery = '';
 
-      if (fullSpan?.sentry_tags && isValidJson(fullSpan?.sentry_tags?.description)) {
+      if (fullSpan?.sentry_tags) {
         stringifiedQuery = prettyPrintJsonString(fullSpan?.sentry_tags?.description);
-      } else if (isValidJson(description)) {
+      } else if (description) {
         stringifiedQuery = prettyPrintJsonString(description);
+      } else if (fullSpan?.sentry_tags?.description) {
+        stringifiedQuery = prettyPrintJsonString(fullSpan?.sentry_tags?.description);
       } else {
         stringifiedQuery = description || fullSpan?.sentry_tags?.description || 'N/A';
       }

+ 13 - 4
static/app/views/insights/database/utils/formatMongoDBQuery.spec.tsx

@@ -87,9 +87,18 @@ describe('formatMongoDBQuery', function () {
     );
   });
 
-  it('returns an unformatted string when given invalid JSON', function () {
-    const query = "{'foo': 'bar'}";
-    const tokenizedQuery = formatMongoDBQuery(query, 'find');
-    expect(tokenizedQuery).toEqual(query);
+  it('handles truncated MongoDB query strings by repairing the JSON', function () {
+    const query = `{"_id":{},"test":"?","insert":"some_collection","address":"?","details":{"email":"?","nam*`;
+    const tokenizedQuery = formatMongoDBQuery(query, 'insert');
+    render(<Fragment>{tokenizedQuery}</Fragment>);
+
+    const boldedText = screen.getByText(/"insert": "some_collection"/i);
+    expect(boldedText).toContainHTML('<b>"insert": "some_collection"</b>');
+
+    // The last entry in this case will be repaired by assigning a null value to the incomplete key
+    const truncatedEntry = screen.getByText(
+      /"details": \{ "email": "\?", "nam\*": null \}/i
+    );
+    expect(truncatedEntry).toBeInTheDocument();
   });
 });

+ 7 - 1
static/app/views/insights/database/utils/formatMongoDBQuery.tsx

@@ -1,5 +1,6 @@
 import type {ReactElement} from 'react';
 import * as Sentry from '@sentry/react';
+import {jsonrepair} from 'jsonrepair';
 
 type JSONValue = string | number | object | boolean | null;
 
@@ -26,7 +27,12 @@ export function formatMongoDBQuery(query: string, command: string) {
   try {
     queryObject = JSON.parse(query);
   } catch {
-    return query;
+    try {
+      const repairedJson = jsonrepair(query);
+      queryObject = JSON.parse(repairedJson);
+    } catch {
+      return query;
+    }
   }
 
   const tokens: ReactElement[] = [];

+ 13 - 1
static/app/views/insights/database/utils/jsonUtils.tsx

@@ -1,3 +1,5 @@
+import {jsonrepair} from 'jsonrepair';
+
 export const isValidJson = (str: string) => {
   try {
     JSON.parse(str);
@@ -8,5 +10,15 @@ export const isValidJson = (str: string) => {
 };
 
 export function prettyPrintJsonString(json: string) {
-  return JSON.stringify(JSON.parse(json), null, 4);
+  try {
+    return JSON.stringify(JSON.parse(json), null, 4);
+  } catch {
+    // Attempt to repair the JSON
+    try {
+      const repairedJson = jsonrepair(json);
+      return JSON.stringify(JSON.parse(repairedJson), null, 4);
+    } catch {
+      return json;
+    }
+  }
 }

+ 5 - 0
yarn.lock

@@ -8688,6 +8688,11 @@ jsonfile@^6.0.1:
   optionalDependencies:
     graceful-fs "^4.1.6"
 
+jsonrepair@^3.8.0:
+  version "3.8.0"
+  resolved "https://registry.yarnpkg.com/jsonrepair/-/jsonrepair-3.8.0.tgz#33a1b0d3630c452e9945ef07d760469cdfad8823"
+  integrity sha512-89lrxpwp+IEcJ6kwglF0HH3Tl17J08JEpYfXnvvjdp4zV4rjSoGu2NdQHxBs7yTOk3ETjTn9du48pBy8iBqj1w==
+
 "jsx-ast-utils@^2.4.1 || ^3.0.0":
   version "3.3.5"
   resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a"