瀏覽代碼

Merge pull request #32710 from overleaf/mg-project-history-metrics

 Add diagnostic annotations to LazyStringFileData toEager errors

GitOrigin-RevId: 47575586bb869d65e4eb443cc9f1215b6f245255
Malik Glossop 3 月之前
父節點
當前提交
ba182f8275

+ 44 - 3
libraries/overleaf-editor-core/lib/file_data/lazy_string_file_data.js

@@ -4,11 +4,13 @@
 const _ = require('lodash')
 const assert = require('check-types').assert
 
+const OError = require('@overleaf/o-error')
 const Blob = require('../blob')
 const FileData = require('./')
 const EagerStringFileData = require('./string_file_data')
 const EditOperation = require('../operation/edit_operation')
 const EditOperationBuilder = require('../operation/edit_operation_builder')
+const TextOperation = require('../operation/text_operation')
 
 /**
  *  @import { BlobStore, ReadonlyBlobStore, RangesBlob, RawHashFileData, RawLazyStringFileData } from '../types'
@@ -152,7 +154,25 @@ class LazyStringFileData extends FileData {
       ranges?.comments,
       ranges?.trackedChanges
     )
-    applyOperations(this.operations, file)
+    try {
+      applyOperations(this.operations, file)
+    } catch (err) {
+      const firstOp = this.operations[0]
+      const firstOpBaseLength =
+        firstOp instanceof TextOperation ? firstOp.baseLength : undefined
+      throw OError.tag(err, 'failed to apply operations in toEager', {
+        blobHash: this.hash,
+        blobContentLength: content.length,
+        metadataStringLength: this.stringLength,
+        totalOperations: this.operations.length,
+        firstOpBaseLength,
+        contentMatchesMetadata: content.length === this.stringLength,
+        contentMatchesFirstOp:
+          typeof firstOpBaseLength === 'number'
+            ? content.length === firstOpBaseLength
+            : undefined,
+      })
+    }
     return file
   }
 
@@ -172,7 +192,18 @@ class LazyStringFileData extends FileData {
    * @param {EditOperation} operation
    */
   edit(operation) {
-    this.stringLength = operation.applyToLength(this.stringLength)
+    try {
+      this.stringLength = operation.applyToLength(this.stringLength)
+    } catch (err) {
+      const baseLength =
+        operation instanceof TextOperation ? operation.baseLength : undefined
+      throw OError.tag(err, 'failed to apply operation length in edit', {
+        blobHash: this.hash,
+        metadataStringLength: this.stringLength,
+        operationBaseLength: baseLength,
+        totalExistingOperations: this.operations.length,
+      })
+    }
     this.operations.push(operation)
   }
 
@@ -205,7 +236,17 @@ class LazyStringFileData extends FileData {
  * @returns {void}
  */
 function applyOperations(operations, file) {
-  _.each(operations, operation => operation.apply(file))
+  for (let i = 0; i < operations.length; i++) {
+    try {
+      operations[i].apply(file)
+    } catch (err) {
+      throw OError.tag(err, 'operation failed during applyOperations', {
+        operationIndex: i,
+        totalOperations: operations.length,
+        currentContentLength: file.getStringLength(),
+      })
+    }
+  }
 }
 
 module.exports = LazyStringFileData

+ 82 - 0
libraries/overleaf-editor-core/test/unit/lazy_string_file_data.test.js

@@ -4,6 +4,7 @@
 const _ = require('lodash')
 const { expect } = require('chai')
 const sinon = require('sinon')
+const OError = require('@overleaf/o-error')
 
 const ot = require('../..')
 const File = ot.File
@@ -202,4 +203,85 @@ describe('LazyStringFileData', function () {
     expect(fileData.hash).to.equal(stored.hash)
     expect(fileData.operations).to.deep.equal([])
   })
+
+  describe('error annotation', function () {
+    it('annotates errors in toEager with blob and operation metadata', async function () {
+      const testHash = this.fileHash
+      // Create a file with content length 19 ('the quick brown fox')
+      // then queue an operation that expects a different base length
+      const fileData = new LazyStringFileData(testHash, undefined, 19)
+      // This operation expects base length 999, which won't match the blob
+      const badOp = new TextOperation()
+      badOp.retain(999)
+      fileData.operations.push(badOp)
+      // Manually set stringLength to match the op's baseLength: pushing directly
+      // to operations bypasses edit(), which is what normally updates stringLength
+      fileData.stringLength = 999
+
+      try {
+        await fileData.toEager(this.blobStore)
+        expect.fail('should have thrown')
+      } catch (err) {
+        const info = OError.getFullInfo(err)
+        expect(info).to.have.property('blobHash', testHash)
+        expect(info).to.have.property('blobContentLength', 19)
+        expect(info).to.have.property('metadataStringLength', 999)
+        expect(info).to.have.property('totalOperations', 1)
+        expect(info).to.have.property('operationIndex', 0)
+        expect(info).to.have.property('currentContentLength', 19)
+        expect(info).to.have.property('firstOpBaseLength', 999)
+        expect(info).to.have.property('contentMatchesMetadata', false)
+        expect(info).to.have.property('contentMatchesFirstOp', false)
+      }
+    })
+
+    it('annotates errors in edit with operation and metadata context', function () {
+      const testHash = this.fileHash
+      const fileData = new LazyStringFileData(testHash, undefined, 19)
+      // Queue one valid operation first so totalExistingOperations > 0
+      fileData.edit(new TextOperation().retain(19).insert('!'))
+      // This operation expects base length 999, mismatching stringLength of 20
+      const badOp = new TextOperation()
+      badOp.retain(999)
+      try {
+        fileData.edit(badOp)
+        expect.fail('should have thrown')
+      } catch (err) {
+        const info = OError.getFullInfo(err)
+        expect(info).to.have.property('blobHash', testHash)
+        expect(info).to.have.property('metadataStringLength', 20)
+        expect(info).to.have.property('operationBaseLength', 999)
+        expect(info).to.have.property('totalExistingOperations', 1)
+      }
+    })
+
+    it('annotates errors in applyOperations with the failing operation index', async function () {
+      const testHash = this.fileHash
+      // Content is 'the quick brown fox' (length 19)
+      const fileData = new LazyStringFileData(testHash, undefined, 19)
+      // First op is valid: insert at end
+      const goodOp = new TextOperation().retain(19).insert('!')
+      // Second op expects length 999 — will fail
+      const badOp = new TextOperation()
+      badOp.retain(999)
+      fileData.operations.push(goodOp)
+      fileData.operations.push(badOp)
+      fileData.stringLength = 999
+
+      try {
+        await fileData.toEager(this.blobStore)
+        expect.fail('should have thrown')
+      } catch (err) {
+        const info = OError.getFullInfo(err)
+        // The second operation (index 1) should be the one that fails
+        expect(info).to.have.property('operationIndex', 1)
+        expect(info).to.have.property('totalOperations', 2)
+        expect(info).to.have.property('currentContentLength', 20)
+        // toEager also tags with blob metadata
+        expect(info).to.have.property('blobHash', testHash)
+        expect(info).to.have.property('blobContentLength', 19)
+        expect(info).to.have.property('metadataStringLength', 999)
+      }
+    })
+  })
 })

+ 12 - 1
services/web/frontend/js/infrastructure/error-reporter.ts

@@ -110,7 +110,7 @@ function sentryReporter() {
             /extensions\//i,
             /^chrome:\/\//i,
           ],
-          beforeSend(event) {
+          beforeSend(event, hint) {
             // Limit number of events sent to Sentry to 100 events "per page load",
             // (i.e. the cap will be reset if the page is reloaded). This prevent
             // hitting their server-side event cap.
@@ -144,6 +144,17 @@ function sentryReporter() {
               return null
             }
 
+            // Extract OError tag info so it appears in Sentry extra for all
+            // captured errors, including auto-captured unhandled rejections
+            // that bypass our captureException() wrapper.
+            const originalException = hint?.originalException
+            if (originalException && typeof originalException === 'object') {
+              const oErrorInfo = OError.getFullInfo(originalException)
+              if (Object.keys(oErrorInfo).length > 0) {
+                event.extra = { ...event.extra, ...oErrorInfo }
+              }
+            }
+
             return sanitizeUrls(event)
           },
         })