From ba182f827540e059eecb7ed14c5516841bdaff21 Mon Sep 17 00:00:00 2001 From: Malik Glossop Date: Tue, 28 Apr 2026 13:35:03 +0200 Subject: [PATCH] Merge pull request #32710 from overleaf/mg-project-history-metrics Add diagnostic annotations to LazyStringFileData toEager errors GitOrigin-RevId: 47575586bb869d65e4eb443cc9f1215b6f245255 --- .../lib/file_data/lazy_string_file_data.js | 47 ++++++++++- .../test/unit/lazy_string_file_data.test.js | 82 +++++++++++++++++++ .../js/infrastructure/error-reporter.ts | 13 ++- 3 files changed, 138 insertions(+), 4 deletions(-) diff --git a/libraries/overleaf-editor-core/lib/file_data/lazy_string_file_data.js b/libraries/overleaf-editor-core/lib/file_data/lazy_string_file_data.js index fd06524595..c05f47047a 100644 --- a/libraries/overleaf-editor-core/lib/file_data/lazy_string_file_data.js +++ b/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 diff --git a/libraries/overleaf-editor-core/test/unit/lazy_string_file_data.test.js b/libraries/overleaf-editor-core/test/unit/lazy_string_file_data.test.js index 7da0fa0ceb..8a22b78532 100644 --- a/libraries/overleaf-editor-core/test/unit/lazy_string_file_data.test.js +++ b/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) + } + }) + }) }) diff --git a/services/web/frontend/js/infrastructure/error-reporter.ts b/services/web/frontend/js/infrastructure/error-reporter.ts index 6b7bb0f0a4..f128351d10 100644 --- a/services/web/frontend/js/infrastructure/error-reporter.ts +++ b/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) }, })