diff --git a/packages/backend/src/adapters/adapters.service.spec.ts b/packages/backend/src/adapters/adapters.service.spec.ts index 3bede6e9..ff83bd29 100644 --- a/packages/backend/src/adapters/adapters.service.spec.ts +++ b/packages/backend/src/adapters/adapters.service.spec.ts @@ -239,7 +239,10 @@ describe('AdaptersService install — a base URL variable without https://', () // The import probe looks the connector up; nothing found = no probe. findUnique: jest.fn().mockResolvedValue(null), }, - mcpTool: { create: jest.fn().mockResolvedValue({}) }, + mcpTool: { + create: jest.fn().mockResolvedValue({}), + createMany: jest.fn(async ({ data }: any) => ({ count: data.length })), + }, }; const service = new AdaptersService( prisma as any, diff --git a/packages/backend/src/adapters/adapters.service.ts b/packages/backend/src/adapters/adapters.service.ts index f948ae42..473fa9d2 100644 --- a/packages/backend/src/adapters/adapters.service.ts +++ b/packages/backend/src/adapters/adapters.service.ts @@ -464,29 +464,41 @@ export class AdaptersService { } } - for (const tool of toolsToCreate) { - try { - await this.prisma.mcpTool.create({ - data: { - connectorId: connector.id, - name: tool.name, - description: tool.description, - isEnabled: tool.enabled !== false, - // Seed the proxy preference from the adapter spec (default off). - useProxy: tool.useProxy === true, - parameters: tool.parameters as any, - endpointMapping: tool.endpointMapping as any, - responseMapping: tool.responseMapping as any, - outputSchema: ((tool as any).outputSchema ?? null) as any, - annotations: (tool.annotations ?? undefined) as any, - // A catalog tool: catalog updates may change or retire it. - origin: 'catalog', - }, - }); - toolsCreated++; - } catch (err: any) { - if (err.code !== 'P2002') { - this.logger.warn(`Failed to create tool ${tool.name}: ${err.message}`); + const toolRows = toolsToCreate.map((tool) => ({ + connectorId: connector.id, + name: tool.name, + description: tool.description, + isEnabled: tool.enabled !== false, + // Seed the proxy preference from the adapter spec (default off). + useProxy: tool.useProxy === true, + parameters: tool.parameters as any, + endpointMapping: tool.endpointMapping as any, + responseMapping: tool.responseMapping as any, + outputSchema: ((tool as any).outputSchema ?? null) as any, + annotations: (tool.annotations ?? undefined) as any, + // A catalog tool: catalog updates may change or retire it. + origin: 'catalog', + })); + + try { + // One insert for the whole adapter instead of one per tool + // (ANYTHINGMCP-CLOUD-BACKEND-5: 40-odd inserts for Telegram Bot). A + // name the connector already has is skipped, as before. + toolsCreated = ( + await this.prisma.mcpTool.createMany({ data: toolRows, skipDuplicates: true }) + ).count; + } catch (err: any) { + // A row the batch cannot take would otherwise lose every tool: insert + // one by one so only that tool is left out, as the import always did. + this.logger.warn(`Batch insert of the "${slug}" tools failed (${err.message}); inserting one by one`); + for (const data of toolRows) { + try { + await this.prisma.mcpTool.create({ data }); + toolsCreated++; + } catch (e: any) { + if (e.code !== 'P2002') { + this.logger.warn(`Failed to create tool ${data.name}: ${e.message}`); + } } } } diff --git a/packages/backend/src/adapters/mcp-adapter.spec.ts b/packages/backend/src/adapters/mcp-adapter.spec.ts index 23c773ec..95e4dfe7 100644 --- a/packages/backend/src/adapters/mcp-adapter.spec.ts +++ b/packages/backend/src/adapters/mcp-adapter.spec.ts @@ -75,6 +75,10 @@ describe('importAdapter for an MCP adapter (splunk)', () => { created.push(data); return data; }), + createMany: jest.fn(async ({ data }: any) => { + created.push(...data); + return { count: data.length }; + }), }, }; const service = new AdaptersService( @@ -131,4 +135,18 @@ describe('importAdapter for an MCP adapter (splunk)', () => { expect(out.probe).toMatchObject({ ok: false, toolName: 'tools/list', status: 403 }); expect((out.probe as any).message).toContain('invalid token audience'); }); + + it('inserts the tools in one batch, and one by one only if the batch fails', async () => { + const discover = jest.fn().mockResolvedValue([remote('splunk_get_info'), remote('splunk_get_indexes')]); + const { service, prisma } = build(discover); + await service.importAdapter('splunk', 'u1', 'o1', creds); + expect(prisma.mcpTool.createMany).toHaveBeenCalledTimes(1); + expect(prisma.mcpTool.create).not.toHaveBeenCalled(); + + const fallback = build(jest.fn().mockResolvedValue([remote('splunk_get_info'), remote('splunk_get_indexes')])); + fallback.prisma.mcpTool.createMany.mockRejectedValueOnce(new Error('batch refused')); + const out = await fallback.service.importAdapter('splunk', 'u1', 'o1', creds); + expect(fallback.prisma.mcpTool.create).toHaveBeenCalledTimes(2); + expect(out.toolsCreated).toBe(2); + }); }); diff --git a/packages/frontend/src/instrumentation-client.ts b/packages/frontend/src/instrumentation-client.ts index fc06ec91..510c8386 100644 --- a/packages/frontend/src/instrumentation-client.ts +++ b/packages/frontend/src/instrumentation-client.ts @@ -11,6 +11,7 @@ import { BROWSER_CONFIG_GLOBAL, BROWSER_DENY_URLS, BROWSER_IGNORE_ERRORS, + beforeSendBrowser, scrubBreadcrumb, scrubEvent, type BrowserSentryConfig, @@ -32,7 +33,8 @@ function init(): void { replaysSessionSampleRate: 0, replaysOnErrorSampleRate: 0, sendDefaultPii: false, - beforeSend: scrubEvent, + // Drops errors thrown entirely inside scripts the browser injected. + beforeSend: beforeSendBrowser, beforeSendTransaction: scrubEvent, beforeBreadcrumb: scrubBreadcrumb, // Noise from extensions and link scanners: see lib/sentry-scrub.ts. diff --git a/packages/frontend/src/lib/sentry-scrub.ts b/packages/frontend/src/lib/sentry-scrub.ts index c1e04e14..69213697 100644 --- a/packages/frontend/src/lib/sentry-scrub.ts +++ b/packages/frontend/src/lib/sentry-scrub.ts @@ -123,6 +123,28 @@ export const BROWSER_IGNORE_ERRORS: Array = [ /Invalid call to runtime\.sendMessage\(\)\. Tab not found/, ]; +const SCRIPT_FILE = /\.[cm]?js(?:[?#]|$)/i; + +/** + * True for an error whose stack runs entirely through code that is not a + * script file: what a browser injects into the page itself. Chrome on iOS + * runs its translate helpers that way; translating /login into Portuguese + * overflowed one of them (ANYTHINGMCP-CLOUD-FRONTEND-9, -A: frames at lines + * 191 and 425 of a 12-line document). None of our /_next/ chunks was on the + * stack. The site drops the same events (ANYTHINGMCP-WEBSITE-K). Errors + * without a stack are kept. + */ +export function isInjectedScriptError(event: Event): boolean { + const frames = (event.exception?.values ?? []).flatMap((v) => v.stacktrace?.frames ?? []); + if (frames.length === 0) return false; + return frames.every((f) => !SCRIPT_FILE.test(f.filename ?? f.abs_path ?? '')); +} + +/** The browser's beforeSend: drop injected-script noise, scrub the rest. */ +export function beforeSendBrowser(event: T): T | null { + return isInjectedScriptError(event) ? null : scrubEvent(event); +} + /** Errors raised by scripts browser extensions inject into the page. */ export const BROWSER_DENY_URLS: RegExp[] = [ /^(chrome|moz|safari(-web)?|ms-browser)-extension:\/\//i, diff --git a/packages/frontend/tests/e2e/sentry-injected-scripts.spec.ts b/packages/frontend/tests/e2e/sentry-injected-scripts.spec.ts new file mode 100644 index 00000000..1d6c4bc6 --- /dev/null +++ b/packages/frontend/tests/e2e/sentry-injected-scripts.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test'; +import type { Event } from '@sentry/nextjs'; +import { beforeSendBrowser, isInjectedScriptError } from '../../src/lib/sentry-scrub'; + +/** + * Errors thrown entirely inside scripts the browser injected (Chrome on iOS + * translating /login, ANYTHINGMCP-CLOUD-FRONTEND-9 and -A) never reach Sentry. + * Anything with a frame in one of our chunks, or without a stack, still does. + */ + +const withFrames = (...filenames: Array): Event => ({ + exception: { values: [{ type: 'RangeError', value: 'x', stacktrace: { frames: filenames.map((filename) => ({ filename })) } }] }, +}); + +test.describe('injected-script errors', () => { + test('drops an error whose whole stack is the page itself', () => { + expect(isInjectedScriptError(withFrames('app:///login', 'app:///login'))).toBe(true); + expect(isInjectedScriptError(withFrames(undefined))).toBe(true); + expect(beforeSendBrowser(withFrames('app:///login'))).toBeNull(); + }); + + test('keeps an error with a frame in one of our chunks', () => { + const ours = withFrames('app:///login', 'app:///_next/static/chunks/0abc.js'); + expect(isInjectedScriptError(ours)).toBe(false); + expect(beforeSendBrowser(ours)).not.toBeNull(); + }); + + test('keeps an error without a stack', () => { + expect(isInjectedScriptError({ exception: { values: [{ type: 'Error', value: 'La' }] } })).toBe(false); + expect(isInjectedScriptError({})).toBe(false); + }); +});