diff --git a/README.md b/README.md index cac2fee..c356ef3 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,31 @@ async bugsplat.post(error, options); // Posts an arbitrary Error object to BugSp // Returns a promise that resolves with properties: error (if there was an error posting to BugSplat), response (the response from the BugSplat crash post API), and original (the error passed by bugsplat.post) ``` +### User Feedback + +You can also submit non-crashing user feedback (e.g. bug reports, feature requests) using `postFeedback`. Feedback reports appear in BugSplat with the "User Feedback" crash type, grouped by `title`. + +```ts +const response = await bugsplat.postFeedback('Login button does not respond', { + description: 'Tapping login on iPhone does nothing.', + user: 'jane@example.com', + email: 'jane@example.com', +}); +``` + +You can attach files such as screenshots: + +```ts +const screenshot = document.querySelector('input[type="file"]').files[0]; + +await bugsplat.postFeedback('UI rendering issue', { + description: 'The sidebar overlaps the main content.', + attachments: [ + { filename: 'screenshot.png', data: screenshot }, + ], +}); +``` + ## 📢 Upgrading If you are developing a Node.js application and were using bugsplat-js <= 5.0.0 please upgrade to [bugsplat-node](https://www.npmjs.com/package/bugsplat-node). BugSplat-node has the same consumer APIs as bugsplat-js <= 5.0.0. Additionally, support for file attachments and exiting the Node process in the error handler have been moved to [bugsplat-node](https://www.npmjs.com/package/bugsplat-node) so that bugsplat-js can be run in browsers as well as Node.js environments. diff --git a/package-lock.json b/package-lock.json index 4333c36..7537cbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "bugsplat", "version": "8.0.2", "license": "MIT", + "dependencies": { + "fflate": "^0.8.2" + }, "devDependencies": { "@bugsplat/js-api-client": "^14.1.0", "@eslint/js": "^9.28.0", @@ -2062,6 +2065,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", diff --git a/package.json b/package.json index e866fa4..ac18c89 100644 --- a/package.json +++ b/package.json @@ -55,5 +55,8 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.33.1", "vitest": "^4.0.16" + }, + "dependencies": { + "fflate": "^0.8.2" } } diff --git a/spec/bugsplat-response.spec.ts b/spec/bugsplat-response.spec.ts index aba7d0f..6853edd 100644 --- a/spec/bugsplat-response.spec.ts +++ b/spec/bugsplat-response.spec.ts @@ -6,24 +6,30 @@ describe('validateResponseBody', () => { const responseBodies = [ { status: 'success', - current_server_time: 12, - message: 'message 1', - url: 'osaiujdhfihfju', - crash_id: 9, + crashId: 9, + stackKeyId: 1, + messageId: 1, + infoUrl: 'https://app.bugsplat.com/browse/crashInfo.php', }, { status: 'fail', - current_server_time: 24, - message: 'message 2', - url: 'http://example.com', - crash_id: 100, + crashId: 100, + stackKeyId: 55, + messageId: 200, + infoUrl: 'https://app.bugsplat.com/browse/crashInfo.php?id=100', }, { status: 'success', - current_server_time: -100, - message: 'message 3', - url: 'http://bugsplat.com/rocks', - crash_id: 333, + crashId: 333, + stackKeyId: 0, + messageId: 0, + infoUrl: 'https://app.bugsplat.com/browse/crashInfo.php?id=333', + }, + { + status: 'success', + crashId: 21417, + stackKeyId: -1, + messageId: -1, }, ]; @@ -41,41 +47,35 @@ describe('validateResponseBody', () => { {}, { status: 'succes', - current_server_time: 12, - message: 'message 1', - url: 'osaiujdhfihfju', - crash_id: 9, + crashId: 9, + stackKeyId: 1, + messageId: 1, + infoUrl: 'https://app.bugsplat.com', }, { - status: 'fail', - current_server_time: 24, - message: 'message 2', - url: new Date(), - crash_id: 100, - }, - { - current_server_time: -100, - message: 'message 3', - url: 'http://bugsplat.com/rocks', - crash_id: 333, + status: 'success', + stackKeyId: 1, + messageId: 1, + infoUrl: 'https://app.bugsplat.com', }, { status: 'success', - message: 'message 3', - url: 'http://bugsplat.com/rocks', - crash_id: 333, + crashId: 9, + messageId: 1, + infoUrl: 'https://app.bugsplat.com', }, { status: 'success', - current_server_time: -100, - url: 'http://bugsplat.com/rocks', - crash_id: 333, + crashId: 9, + stackKeyId: 1, + infoUrl: 'https://app.bugsplat.com', }, { status: 'success', - current_server_time: -100, - message: 'message 3', - url: 'http://bugsplat.com/rocks', + crashId: 9, + stackKeyId: 1, + messageId: 1, + infoUrl: 42, }, ]; diff --git a/spec/bugsplat.e2e.spec.ts b/spec/bugsplat.e2e.spec.ts index bd234fa..9699e75 100644 --- a/spec/bugsplat.e2e.spec.ts +++ b/spec/bugsplat.e2e.spec.ts @@ -17,7 +17,10 @@ describe('BugSplat', () => { throw new Error('Please set FRED_PASSWORD environment variable'); } - const api = await BugSplatApiClient.createAuthenticatedClientForNode(email, password); + const api = await BugSplatApiClient.createAuthenticatedClientForNode( + email, + password, + ); client = new CrashApiClient(api); // Posting too frequently results in 400s from the web server @@ -36,26 +39,62 @@ describe('BugSplat', () => { const additionalFile = './spec/files/additionalFile.txt'; const fileName = path.basename(additionalFile); const fileContents = await readFile(additionalFile); - const additionalFormDataParams = [ - { - key: fileName, - value: new Blob([new Uint8Array(fileContents)]), - filename: fileName, - }, - { key: 'attributes', value: '{"foo": "bar"}' }, - ]; const bugsplat = new BugSplat(database, appName, appVersion); bugsplat.setDefaultAppKey(appKey); bugsplat.setDefaultUser(user); bugsplat.setDefaultEmail(email); bugsplat.setDefaultDescription(description); - const result = await bugsplat.post(error, { additionalFormDataParams }); + const result = await bugsplat.post(error, { + attachments: [ + { data: new Uint8Array(fileContents), filename: fileName }, + ], + attributes: { foo: 'bar' }, + }); if (result.error) { throw new Error(result.error.message); } - const expectedCrashId = result.response.crash_id; + const expectedCrashId = result.response.crashId; + const crashData = await client.getCrashById(database, expectedCrashId); + expect(crashData.appName).toEqual(appName); + expect(crashData.appVersion).toEqual(appVersion); + expect(crashData.appKey).toEqual(appKey); + expect(crashData.description).toEqual(description); + expect(crashData.user).toBeTruthy(); // Fred has PII obfuscated so the best we can do here is to check if truthy + expect(crashData.email).toBeTruthy(); // Fred has PII obfuscated so the best we can do here is to check if truthy + }, 30000); + + it('should post a feedback report with all provided information', async () => { + const database = 'fred'; + const appName = 'my-node-crasher'; + const appVersion = '1.2.3.4'; + const appKey = 'Key!'; + const user = 'User!'; + const email = 'fred@bedrock.com'; + const title = 'Symbol Store Size Limit'; + const description = + 'I hit the 8 GB symbol size limit and can no longer upload symbols'; + const additionalFile = './spec/files/additionalFile.txt'; + const fileName = path.basename(additionalFile); + const fileContents = await readFile(additionalFile); + const bugsplat = new BugSplat(database, appName, appVersion); + bugsplat.setDefaultAppKey(appKey); + bugsplat.setDefaultUser(user); + bugsplat.setDefaultEmail(email); + bugsplat.setDefaultDescription(description); + + const result = await bugsplat.postFeedback(title, { + attachments: [ + { data: new Uint8Array(fileContents), filename: fileName }, + ], + attributes: { foo: 'bar' }, + }); + if (result.error) { + throw new Error(result.error.message); + } + + const expectedCrashId = result.response.crashId; const crashData = await client.getCrashById(database, expectedCrashId); expect(crashData.appName).toEqual(appName); expect(crashData.appVersion).toEqual(appVersion); @@ -76,7 +115,7 @@ describe('BugSplat', () => { if (result.error) { throw new Error(result.error.message); } - const expectedCrashId = result.response.crash_id; + const expectedCrashId = result.response.crashId; const crashData = await client.getCrashById(database, expectedCrashId); expect(crashData.appName).toEqual(appName); diff --git a/spec/bugsplat.spec.ts b/spec/bugsplat.spec.ts index 0aefbeb..7f0f9af 100644 --- a/spec/bugsplat.spec.ts +++ b/spec/bugsplat.spec.ts @@ -4,7 +4,6 @@ import { createStandardizedCallStack, tryParseResponseJson, } from '../src/bugsplat'; -import { Blob } from 'buffer'; describe('createStandardizedCallStack', () => { it('should always return a call stack containing "Error:"', () => { @@ -72,315 +71,256 @@ describe('BugSplat', function () { const database = 'fred'; const appName = 'my-node-crasher'; const appVersion = '1.0.0.0'; - const expectedStatus = 'success'; const expectedCrashId = 73180; - let bugsplat; + let bugsplat: BugSplat; let appendSpy: Mock; let fakeFormData; - let fakeCrashResponse; - let fakeSuccessResponseBody; let fetchSpy: Mock; + const fakePresignedUrlResponse = { + ok: true, + json: async () => ({ url: 'https://s3.example.com/presigned' }), + }; + + const fakePutResponse = { + ok: true, + headers: { get: (key: string) => key === 'ETag' ? '"abc123"' : null }, + }; + + const fakeCommitResponse = { + ok: true, + status: 200, + json: async () => ({ + status: 'success', + crashId: expectedCrashId, + stackKeyId: 1, + messageId: 1, + infoUrl: 'https://app.bugsplat.com/browse/crashInfo.php', + }), + }; + beforeEach(() => { appendSpy = vi.fn(); fakeFormData = { append: appendSpy, toString: () => 'BugSplat rocks!' }; - fakeCrashResponse = { - status: 'success', - current_server_time: 1, - message: 'BugSplat rocks!', - url: 'bugsplat.rocks/yes-its-true', - crash_id: expectedCrashId, - }; - fakeSuccessResponseBody = { - status: expectedStatus, - json: async () => fakeCrashResponse, - ok: true, - }; bugsplat = new BugSplat(database, appName, appVersion); + // @ts-expect-error -- accessing private field for test mocking bugsplat._formData = () => fakeFormData; fetchSpy = vi.spyOn(globalThis, 'fetch') as unknown as Mock; + fetchSpy + .mockResolvedValueOnce(fakePresignedUrlResponse) + .mockResolvedValueOnce(fakePutResponse) + .mockResolvedValueOnce(fakeCommitResponse); }); - describe('when options.additionalFormDataParams is set', () => { - it('should call append with key and value if passed string value', async () => { - const key = 'attachment.txt'; - const value = '🐶'; - const additionalFormDataParams = [{ key, value }]; - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); + describe('presigned URL upload flow', () => { + it('should request a presigned URL with correct query params', async () => { + await bugsplat.post(new Error('BugSplat!')); - await bugsplat.post(new Error('BugSplat!'), { - additionalFormDataParams, - }); + const url = new URL(fetchSpy.mock.calls[0][0]); + expect(url.pathname).toEqual('/api/getCrashUploadUrl'); + expect(url.searchParams.get('database')).toEqual(database); + expect(url.searchParams.get('appName')).toEqual(appName); + expect(url.searchParams.get('appVersion')).toEqual(appVersion); + }); - expect(appendSpy).toHaveBeenCalledWith(key, value); + it('should PUT to the presigned URL', async () => { + await bugsplat.post(new Error('BugSplat!')); + + expect(fetchSpy.mock.calls[1][0]).toEqual('https://s3.example.com/presigned'); + expect(fetchSpy.mock.calls[1][1].method).toEqual('PUT'); }); - it('should call append with key, value and filename if passed Blob value', async () => { - const key = 'attachment.txt'; - const value = new Blob([]); - const filename = key; - const additionalFormDataParams = [{ key, value, filename }]; - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); + it('should commit with correct form data fields', async () => { + const appKey = 'myKey'; + const user = 'myUser'; + const email = 'my@email.com'; + const description = 'myDescription'; await bugsplat.post(new Error('BugSplat!'), { - additionalFormDataParams, + appKey, + user, + email, + description, }); - expect(appendSpy).toHaveBeenCalledWith(key, value, filename); + expect(appendSpy).toHaveBeenCalledWith('database', database); + expect(appendSpy).toHaveBeenCalledWith('appName', appName); + expect(appendSpy).toHaveBeenCalledWith('appVersion', appVersion); + expect(appendSpy).toHaveBeenCalledWith('crashTypeId', '14'); + expect(appendSpy).toHaveBeenCalledWith('s3Key', 'https://s3.example.com/presigned'); + expect(appendSpy).toHaveBeenCalledWith('md5', 'abc123'); + expect(appendSpy).toHaveBeenCalledWith('appKey', appKey); + expect(appendSpy).toHaveBeenCalledWith('user', user); + expect(appendSpy).toHaveBeenCalledWith('email', email); + expect(appendSpy).toHaveBeenCalledWith('description', description); }); - }); - it('should use default appKey if options.appKey is not set', async () => { - await createDefaultPropertyTest( - bugsplat, - 'appKey', - 'defaultAppKey', - bugsplat.setDefaultAppKey.bind(bugsplat) - ); - }); - - it('should use options.appKey if set', async () => { - const appKey = 'overridenAppKey'; - await createOptionsOverrideTest(bugsplat, { appKey }, 'appKey', appKey); - }); + it('should commit with crashTypeId 36 for feedback', async () => { + await bugsplat.postFeedback('My feedback'); - it('should use default user if options.user is not set', async () => { - await createDefaultPropertyTest( - bugsplat, - 'user', - 'defaultUser', - bugsplat.setDefaultUser.bind(bugsplat) - ); + expect(appendSpy).toHaveBeenCalledWith('crashTypeId', '36'); + }); }); - it('should use options.user if set', async () => { - const user = 'overridenUser'; - await createOptionsOverrideTest(bugsplat, { user }, 'user', user); - }); + describe('when options.attributes is set', () => { + it('should append JSON-serialized attributes to commit body', async () => { + const attributes = { foo: 'bar', baz: 'qux' }; - it('should use default email if options.email is not set', async () => { - await createDefaultPropertyTest( - bugsplat, - 'email', - 'defaultEmail', - bugsplat.setDefaultEmail.bind(bugsplat) - ); - }); + await bugsplat.post(new Error('BugSplat!'), { attributes }); - it('should use options.email if set', async () => { - const email = 'overridenEmail'; - await createOptionsOverrideTest(bugsplat, { email }, 'email', email); - }); + expect(appendSpy).toHaveBeenCalledWith( + 'attributes', + JSON.stringify(attributes) + ); + }); - it('should use default description if options.description is not set', async () => { - await createDefaultPropertyTest( - bugsplat, - 'description', - 'defaultDescription', - bugsplat.setDefaultDescription.bind(bugsplat) - ); - }); + it('should not append attributes if empty object', async () => { + await bugsplat.post(new Error('BugSplat!'), { attributes: {} }); - it('should use options.description if set', async () => { - const description = 'overridenDescription'; - await createOptionsOverrideTest( - bugsplat, - { description }, - 'description', - description - ); - }); + expect(appendSpy).not.toHaveBeenCalledWith( + 'attributes', + expect.anything() + ); + }); - it('should append database to post body', async () => { - await createDefaultPropertyTest(bugsplat, 'database', database); - }); + it('should use default attributes if options.attributes is not set', async () => { + const attributes = { env: 'production' }; + bugsplat.setDefaultAttributes(attributes); - it('should append appName to post body', async () => { - await createDefaultPropertyTest(bugsplat, 'appName', appName); - }); + await bugsplat.post(new Error('BugSplat!'), {}); - it('should append appVersion to post body', async () => { - await createDefaultPropertyTest(bugsplat, 'appVersion', appVersion); - }); + expect(appendSpy).toHaveBeenCalledWith( + 'attributes', + JSON.stringify(attributes) + ); + }); - it('should append callstack to post body', async () => { - const expectedError = new Error('BugSplat!'); - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); + it('should use options.attributes over default attributes', async () => { + const defaultAttributes = { env: 'production' }; + const overrideAttributes = { env: 'staging' }; + bugsplat.setDefaultAttributes(defaultAttributes); - await bugsplat.post(expectedError, {}); + await bugsplat.post(new Error('BugSplat!'), { + attributes: overrideAttributes, + }); - expect(appendSpy).toHaveBeenCalledWith( - 'callstack', - expectedError.stack - ); + expect(appendSpy).toHaveBeenCalledWith( + 'attributes', + JSON.stringify(overrideAttributes) + ); + }); }); - it('should create a stack if none was provided', async () => { - const expectedError = 'Error without a stack!'; - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); - - await bugsplat.post(expectedError, {}); - - expect(appendSpy).toHaveBeenCalledWith( - 'callstack', - expect.stringMatching( - new RegExp( - `Error: ${expectedError}.*\n.*at BugSplat\\.` - ) - ) - ); + it('should use default appKey if options.appKey is not set', async () => { + bugsplat.setDefaultAppKey('defaultAppKey'); + await bugsplat.post(new Error('BugSplat!'), {}); + expect(appendSpy).toHaveBeenCalledWith('appKey', 'defaultAppKey'); }); - it('should create a stack if stack is only spaces', async () => { - const expectedError = ' '; - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); - - await bugsplat.post(expectedError, {}); - - expect(appendSpy).toHaveBeenCalledWith( - 'callstack', - expect.stringMatching( - new RegExp( - `Error: ${expectedError}.*\n.*at BugSplat\\.` - ) - ) - ); + it('should use options.appKey if set', async () => { + await bugsplat.post(new Error('BugSplat!'), { appKey: 'overridenAppKey' }); + expect(appendSpy).toHaveBeenCalledWith('appKey', 'overridenAppKey'); }); - it('should reconstruct error line of callstack if not provided by browser (Safari)', async () => { - const error = { - message: 'Stack without a message', - stack: 'handlError/<@https://app.bugsplat.com/v2/main-es2015.32bd4307e375ff22d168.js:1:1413880>', - }; - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); - - await bugsplat.post(error, {}); - - expect(appendSpy).toHaveBeenCalledWith( - 'callstack', - expect.stringMatching( - new RegExp(`Error: ${error.message}.*\n${error.stack}`) - ) - ); + it('should use default user if options.user is not set', async () => { + bugsplat.setDefaultUser('defaultUser'); + await bugsplat.post(new Error('BugSplat!'), {}); + expect(appendSpy).toHaveBeenCalledWith('user', 'defaultUser'); }); - it('should call fetch url containing database', async () => { - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); - - await bugsplat.post(new Error('BugSplat!')); + it('should use options.user if set', async () => { + await bugsplat.post(new Error('BugSplat!'), { user: 'overridenUser' }); + expect(appendSpy).toHaveBeenCalledWith('user', 'overridenUser'); + }); - expect(fetchSpy).toHaveBeenCalledWith( - `https://${database}.bugsplat.com/post/js/`, - expect.anything() - ); + it('should use default email if options.email is not set', async () => { + bugsplat.setDefaultEmail('defaultEmail'); + await bugsplat.post(new Error('BugSplat!'), {}); + expect(appendSpy).toHaveBeenCalledWith('email', 'defaultEmail'); }); - it('should call fetch with method and body', async () => { - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); + it('should use options.email if set', async () => { + await bugsplat.post(new Error('BugSplat!'), { email: 'overridenEmail' }); + expect(appendSpy).toHaveBeenCalledWith('email', 'overridenEmail'); + }); - await bugsplat.post(new Error('BugSplat!')); + it('should use default description if options.description is not set', async () => { + bugsplat.setDefaultDescription('defaultDescription'); + await bugsplat.post(new Error('BugSplat!'), {}); + expect(appendSpy).toHaveBeenCalledWith('description', 'defaultDescription'); + }); - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'POST', - body: fakeFormData, - }) - ); + it('should use options.description if set', async () => { + await bugsplat.post(new Error('BugSplat!'), { description: 'overridenDescription' }); + expect(appendSpy).toHaveBeenCalledWith('description', 'overridenDescription'); }); - it('should return response body and original error if BugSplat POST returns 200', async () => { + it('should return response body and original error on success', async () => { const errorToPost = new Error('BugSplat!'); - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); const result = await bugsplat.post(errorToPost, {}); - expect(result.error).toBeFalsy(); - expect(result.response.crash_id).toEqual(expectedCrashId); - expect(result.original.message).toEqual(errorToPost.message); + expect(result.error).toBeNull(); + if (!result.error) { + expect(result.response.crashId).toEqual(expectedCrashId); + } + expect(result.original).toBe(errorToPost); }); - it('should return BugSplat error, response body and original error if BugSplat POST returns an invalid response', async () => { - const errorToPost = new Error('BugSplat!'); - fetchSpy.mockResolvedValue({ - status: 200, - json: async () => ({}), - ok: true, - }); + it('should return error if presigned URL request fails', async () => { + fetchSpy.mockReset(); + fetchSpy.mockResolvedValueOnce({ ok: false }); - const result = await bugsplat.post(errorToPost, {}); - expect(result.error.message).toEqual( - 'BugSplat Error: Invalid response received' - ); - expect(result.original.message).toEqual(errorToPost.message); - }); + const result = await bugsplat.post(new Error('BugSplat!'), {}); - it('should return BugSplat error, response body and original error if BugSplat POST returns 400', async () => { - const errorToPost = new Error('BugSplat!'); - fetchSpy.mockResolvedValue({ - status: 400, - json: async () => ({}), - }); - - const result = await bugsplat.post(errorToPost, {}); - expect(result.error.message).toEqual('BugSplat Error: Bad request'); - expect(result.original.message).toEqual(errorToPost.message); + expect(result.error).not.toBeNull(); + expect(result.error!.message).toEqual('BugSplat Error: Failed to get upload URL'); }); - it('should return BugSplat error, response body and original error if BugSplat POST returns 429', async () => { - const errorToPost = new Error('BugSplat!'); - fetchSpy.mockResolvedValue({ - status: 429, - json: async () => ({}), - }); + it('should return error if S3 upload fails', async () => { + fetchSpy.mockReset(); + fetchSpy + .mockResolvedValueOnce(fakePresignedUrlResponse) + .mockResolvedValueOnce({ ok: false }); - const result = await bugsplat.post(errorToPost, {}); - expect(result.error.message).toEqual( - 'BugSplat Error: Rate limit of one crash per second exceeded' - ); - expect(result.original.message).toEqual(errorToPost.message); - }); + const result = await bugsplat.post(new Error('BugSplat!'), {}); - it('should return BugSplat error, response body and original error for unknown BugSplat POST error', async () => { - const errorToPost = new Error('BugSplat!'); - fetchSpy.mockResolvedValue({ - status: 500, - json: async () => ({}), - ok: false, - }); - - const result = await bugsplat.post(errorToPost, {}); - expect(result.error.message).toEqual('BugSplat Error: Unknown error'); - expect(result.original.message).toEqual(errorToPost.message); + expect(result.error).not.toBeNull(); + expect(result.error!.message).toEqual('BugSplat Error: Failed to upload to S3'); }); - async function createDefaultPropertyTest( - bugsplat, - propertyName, - propertyValue, - propertySetter = (_value) => { - // no-op - } - ) { - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); + it('should return error if commit fails', async () => { + fetchSpy.mockReset(); + fetchSpy + .mockResolvedValueOnce(fakePresignedUrlResponse) + .mockResolvedValueOnce(fakePutResponse) + .mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({}), + }); - propertySetter(propertyValue); - await bugsplat.post(new Error('BugSplat!'), {}); + const result = await bugsplat.post(new Error('BugSplat!'), {}); - expect(appendSpy).toHaveBeenCalledWith(propertyName, propertyValue); - } + expect(result.error).not.toBeNull(); + expect(result.error!.message).toEqual('BugSplat Error: Failed to commit upload'); + }); - async function createOptionsOverrideTest( - bugsplat, - postOptions, - propertyName, - propertyValue - ) { - fetchSpy.mockResolvedValue(fakeSuccessResponseBody); + it('should return error if commit response is invalid', async () => { + fetchSpy.mockReset(); + fetchSpy + .mockResolvedValueOnce(fakePresignedUrlResponse) + .mockResolvedValueOnce(fakePutResponse) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + }); - await bugsplat.post(new Error('BugSplat!'), postOptions); + const result = await bugsplat.post(new Error('BugSplat!'), {}); - expect(appendSpy).toHaveBeenCalledWith(propertyName, propertyValue); - } + expect(result.error).not.toBeNull(); + expect(result.error!.message).toEqual('BugSplat Error: Invalid response received'); + }); }); diff --git a/src/bugsplat-options.ts b/src/bugsplat-options.ts index e5a95fd..e3d7cb1 100644 --- a/src/bugsplat-options.ts +++ b/src/bugsplat-options.ts @@ -1,24 +1,39 @@ -import { FormDataParam } from './form-data-param'; +/** + * A file attachment to include in the upload zip. + */ +export interface BugSplatAttachment { + /** + * The filename as it will appear inside the zip + */ + filename: string; + /** + * The file contents + */ + data: Blob | Uint8Array; +} /** - * Additional parameters that can be passed to `post()` + * Additional parameters that can be passed to `post()` or `postFeedback()` * * If any of `appKey`, `user`, `email`, `description` are set, * the corresponding default values will be overwritten */ export interface BugSplatOptions { - /** - * Define arbitrary fields to be appended to the form data - * object to be sent. This is useful to pass any additional - * data as string or `blob`. - */ - additionalFormDataParams?: Array; /** * Additional metadata that can be queried via BugSplat's web application */ appKey?: string; /** - * Additional info about your crash that gets reset after every post + * Key/value attributes to attach to the report. + * These are searchable via BugSplat's web application. + */ + attributes?: Record; + /** + * File attachments to include in the upload zip + */ + attachments?: Array; + /** + * Additional info about the crash or feedback */ description?: string; /** diff --git a/src/bugsplat-response.ts b/src/bugsplat-response.ts index 6413e4a..932abaf 100644 --- a/src/bugsplat-response.ts +++ b/src/bugsplat-response.ts @@ -1,5 +1,5 @@ /** - * Parsed response from BugSplat crash post API. + * Parsed response from BugSplat's commit S3 upload API. */ export interface BugSplatResponseBody { /** @@ -7,23 +7,21 @@ export interface BugSplatResponseBody { */ status: 'success' | 'fail'; /** - * Server time in seconds since epoch. - * - * Get a Date object with: - * ``` - * new Date(response.current_server_time * 1000) - * ``` + * Id of the newly created crash report */ - current_server_time: number; - message: string; + crashId: number; /** - * Support response url + * Id of the stack key group */ - url?: string; + stackKeyId: number; /** - * Id of the newly created crash report + * Id of the message + */ + messageId: number; + /** + * URL to view the crash info (not always present in server response) */ - crash_id: number; + infoUrl?: string; } export interface BugSplatResponseType { @@ -32,11 +30,11 @@ export interface BugSplatResponseType { */ error: ErrorType; /** - * Crash response object. Validated if `error` is null. + * Response object. Validated if `error` is null. */ response: ErrorType extends null ? BugSplatResponseBody : unknown; /** - * The original error posted to BugSplat. + * The original error or title posted to BugSplat. */ original: Error | string; } @@ -49,7 +47,7 @@ const isObject = (val: unknown): val is object => typeof val === 'object' && val !== null; const isString = (val: unknown): val is string => typeof val === 'string'; const isNumber = (val: unknown): val is number => typeof val === 'number'; -const isUndefined = (val: unknown): val is undefined => val === undefined; +const isUndefined = (val: unknown): val is undefined => typeof val === 'undefined'; /** * Ensure the response body has the expected properties. @@ -63,10 +61,10 @@ export function validateResponseBody( const conditions = [ ['success', 'fail'].includes(response['status']), - isNumber(response['current_server_time']), - isString(response['message']), - isString(response['url']) || isUndefined(response['url']), - isNumber(response['crash_id']), + isNumber(response['crashId']), + isNumber(response['stackKeyId']), + isNumber(response['messageId']), + isString(response['infoUrl']) || isUndefined(response['infoUrl']), ]; return conditions.every(Boolean); diff --git a/src/bugsplat.ts b/src/bugsplat.ts index ddffde0..8d73291 100644 --- a/src/bugsplat.ts +++ b/src/bugsplat.ts @@ -1,3 +1,4 @@ +import { zipSync, strToU8 } from 'fflate'; import type { BugSplatOptions } from './bugsplat-options'; import { type BugSplatResponse, @@ -5,7 +6,6 @@ import { type BugSplatResponseType, validateResponseBody, } from './bugsplat-response'; -import { isFormDataParamString } from './form-data-param'; export function createStandardizedCallStack(error: Error): string { if (!error.stack?.includes('Error:')) { @@ -34,13 +34,13 @@ export async function tryParseResponseJson(response: { const isError = (val: unknown): val is Error => Boolean((val as Error)?.stack); /** - * BugSplat crash posting client. Facilitates sending - * crash reports through the `post()` method. + * BugSplat crash and feedback posting client. */ export class BugSplat { private _formData = () => new FormData(); private _appKey = ''; + private _attributes: Record = {}; private _description = ''; private _email = ''; private _user = ''; @@ -52,7 +52,7 @@ export class BugSplat { ) {} /** - * Posts an arbitrary Error object to BugSplat + * Posts an arbitrary Error object to BugSplat via presigned URL upload * @param errorToPost - Error object or a message to be sent to BugSplat * @param options - Additional parameters that can be sent to BugSplat */ @@ -62,78 +62,52 @@ export class BugSplat { ): Promise { options = options || {}; - const appKey = options.appKey || this._appKey; - const user = options.user || this._user; - const email = options.email || this._email; - const description = options.description || this._description; - const additionalFormDataParams = options.additionalFormDataParams || []; const callstack = createStandardizedCallStack( isError(errorToPost) ? errorToPost : new Error(errorToPost) ); - const url = process.env.BUGSPLAT_CRASH_POST_URL || 'https://' + this.database + '.bugsplat.com/post/js/'; - const method = 'POST'; - const body = this._formData(); - body.append('database', this.database); - body.append('appName', this.application); - body.append('appVersion', this.version); - body.append('appKey', appKey); - body.append('user', user); - body.append('email', email); - body.append('description', description); - body.append('callstack', callstack); - additionalFormDataParams.forEach((param) => { - if (isFormDataParamString(param)) { - body.append(param.key, param.value); - } else { - body.append(param.key, param.value, param.filename); - } - }); + const zipFiles: Record = { + 'javascriptCallStack.txt': strToU8(callstack), + }; + for (const attachment of options.attachments || []) { + const bytes = attachment.data instanceof Uint8Array + ? attachment.data + : new Uint8Array(await attachment.data.arrayBuffer()); + zipFiles[attachment.filename] = bytes; + } console.log('BugSplat Error:', errorToPost); - console.log('BugSplat Url:', url); - - const response = await globalThis.fetch(url, { method, body }); - const json = await tryParseResponseJson(response); - console.log('BugSplat POST status code:', response.status); - console.log('BugSplat POST response body:', json); + return this._upload(zipFiles, '14', options, errorToPost); + } - if (response.status === 400) { - return this._createReturnValue( - new Error('BugSplat Error: Bad request'), - json, - errorToPost - ); - } + /** + * Posts user feedback to BugSplat via presigned URL upload + * @param title - Feedback title, used as the stack key for grouping + * @param options - Additional parameters for the feedback submission + */ + async postFeedback( + title: string, + options?: BugSplatOptions + ): Promise { + options = options || {}; - if (response.status === 429) { - return this._createReturnValue( - new Error( - 'BugSplat Error: Rate limit of one crash per second exceeded' - ), - json, - errorToPost - ); - } + const description = options.description || this._description; - if (!response.ok) { - return this._createReturnValue( - new Error('BugSplat Error: Unknown error'), - json, - errorToPost - ); + const feedbackJson = JSON.stringify({ title, description }); + const zipFiles: Record = { + 'feedback.json': strToU8(feedbackJson), + }; + for (const attachment of options.attachments || []) { + const bytes = attachment.data instanceof Uint8Array + ? attachment.data + : new Uint8Array(await attachment.data.arrayBuffer()); + zipFiles[attachment.filename] = bytes; } - if (!validateResponseBody(json)) { - return this._createReturnValue( - new Error('BugSplat Error: Invalid response received'), - json, - errorToPost - ); - } + console.log('BugSplat Feedback:', title); - return this._createReturnValue(null, json, errorToPost); + return this._upload(zipFiles, '36', options, title); } /** @@ -143,6 +117,14 @@ export class BugSplat { this._appKey = appKey; } + /** + * Key/value attributes to attach to crash and feedback reports. + * These are searchable via BugSplat's web application. + */ + setDefaultAttributes(attributes: Record): void { + this._attributes = attributes; + } + /** * Additional info about your crash that gets reset after every post */ @@ -164,6 +146,114 @@ export class BugSplat { this._user = user; } + /** + * Shared presigned URL upload flow used by both post() and postFeedback(). + */ + private async _upload( + zipFiles: Record, + crashTypeId: string, + options: BugSplatOptions, + original: Error | string + ): Promise { + const appKey = options.appKey || this._appKey; + const attributes = options.attributes || this._attributes; + const user = options.user || this._user; + const email = options.email || this._email; + const description = options.description || this._description; + + const zipData = zipSync(zipFiles); + const baseUrl = process.env.BUGSPLAT_BASE_URL || `https://${this.database}.bugsplat.com`; + + // Step 1: Get presigned upload URL + const getCrashUrlParams = new URLSearchParams({ + database: this.database, + appName: this.application, + appVersion: this.version, + crashPostSize: String(zipData.byteLength), + }); + + console.log('BugSplat: Getting presigned URL...'); + + const getCrashUrlResponse = await globalThis.fetch( + `${baseUrl}/api/getCrashUploadUrl?${getCrashUrlParams}` + ); + + if (!getCrashUrlResponse.ok) { + return this._createReturnValue( + new Error('BugSplat Error: Failed to get upload URL'), + {}, + original + ); + } + + const { url: presignedUrl } = await getCrashUrlResponse.json() as { url: string }; + + // Step 2: Upload zip to S3 + console.log('BugSplat: Uploading...'); + + const putResponse = await globalThis.fetch(presignedUrl, { + method: 'PUT', + body: new Blob([zipData.buffer as ArrayBuffer], { type: 'application/zip' }), + headers: { 'Content-Type': 'application/zip' }, + }); + + if (!putResponse.ok) { + return this._createReturnValue( + new Error('BugSplat Error: Failed to upload to S3'), + {}, + original + ); + } + + const etag = putResponse.headers.get('ETag')?.replace(/"/g, '') || ''; + + // Step 3: Commit the upload + console.log('BugSplat: Committing...'); + + const commitBody = this._formData(); + commitBody.append('database', this.database); + commitBody.append('appName', this.application); + commitBody.append('appVersion', this.version); + commitBody.append('crashTypeId', crashTypeId); + commitBody.append('s3Key', presignedUrl); + commitBody.append('md5', etag); + commitBody.append('appKey', appKey); + commitBody.append('user', user); + commitBody.append('email', email); + commitBody.append('description', description); + if (Object.keys(attributes).length > 0) { + commitBody.append('attributes', JSON.stringify(attributes)); + } + + const commitResponse = await globalThis.fetch( + `${baseUrl}/api/commitS3CrashUpload`, + { method: 'POST', body: commitBody } + ); + + const json = await tryParseResponseJson(commitResponse); + + console.log('BugSplat commit status code:', commitResponse.status); + console.log('BugSplat commit response body:', json); + + if (!commitResponse.ok) { + return this._createReturnValue( + new Error('BugSplat Error: Failed to commit upload'), + json, + original + ); + } + + if (!validateResponseBody(json)) { + return this._createReturnValue( + new Error('BugSplat Error: Invalid response received'), + json, + original + ); + } + + return this._createReturnValue(null, json, original); + } + private _createReturnValue( error: ErrorType, response: ErrorType extends null ? BugSplatResponseBody : unknown, diff --git a/src/form-data-param.ts b/src/form-data-param.ts deleted file mode 100644 index 6ad9afa..0000000 --- a/src/form-data-param.ts +++ /dev/null @@ -1,30 +0,0 @@ -interface FormDataParamType { - /** - * The name of the field whose data is contained in `value`. - */ - key: string; - /** - * The field's value. - */ - value: T; - /** - * The filename reported to the server - * when `value` is a `Blob` or `File` - */ - filename?: T extends string ? never : string; -} - -/** - * Simple object that is used to construct `FormData` objects. - * @see https://developer.mozilla.org/en-US/docs/Web/API/FormData/append - */ -export type FormDataParam = FormDataParamType | FormDataParamType; - -/** - * Check if FormField has a string value - */ -export function isFormDataParamString( - param: FormDataParam -): param is FormDataParamType { - return typeof param.value === 'string'; -} diff --git a/src/index.ts b/src/index.ts index 0f1af31..7b7d22d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ export { BugSplat } from './bugsplat'; -export type { BugSplatOptions } from './bugsplat-options'; +export type { BugSplatAttachment, BugSplatOptions } from './bugsplat-options'; export type { BugSplatResponse, BugSplatResponseBody, BugSplatResponseType, validateResponseBody } from './bugsplat-response'; -export type { FormDataParam } from './form-data-param';