diff --git a/apps/sim/lib/selectors/server/providers/http.test.ts b/apps/sim/lib/selectors/server/providers/http.test.ts index d79e3a5da42..b32e09c20f9 100644 --- a/apps/sim/lib/selectors/server/providers/http.test.ts +++ b/apps/sim/lib/selectors/server/providers/http.test.ts @@ -1,3 +1,4 @@ +import { getMockLogger } from '@sim/testing/mocks/logger.mock' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { SelectorConnectionUnavailableError, @@ -96,4 +97,23 @@ describe('provider HTTP selector boundary', () => { expect(cancel).toHaveBeenCalledOnce() } ) + + it('logs a failed provider call without its query string, search text, or email path segments', async () => { + mockFetch.mockResolvedValueOnce(new Response('{}', { status: 404 })) + + await expect( + fetchProviderJson( + "https://www.googleapis.com/calendar/v3/calendars/jane%40example.com/root/search(q='payroll')?access_token=secret" + ) + ).rejects.toBeInstanceOf(SelectorOptionsUnavailableError) + + expect(getMockLogger('SelectorProviderHttp').warn).toHaveBeenCalledWith( + 'Selector provider request failed', + { + endpoint: 'www.googleapis.com/calendar/v3/calendars/{redacted}/root/search()', + reason: 'status', + status: 404, + } + ) + }) }) diff --git a/apps/sim/lib/selectors/server/providers/http.ts b/apps/sim/lib/selectors/server/providers/http.ts index dc12d60b635..98f33b27d2a 100644 --- a/apps/sim/lib/selectors/server/providers/http.ts +++ b/apps/sim/lib/selectors/server/providers/http.ts @@ -48,13 +48,17 @@ async function readBoundedBody(response: Response): Promise { } /** - * Host and path of a provider request. The query string is dropped and OData-style path - * arguments (`search(q='...')`) are blanked, since both can carry tokens or a user's search text. + * Host and path of a provider request, with the parts that can carry user data removed: the query + * string (tokens, filters), OData-style path arguments (`search(q='...')` holds search text), and + * any segment holding an email address (a Google calendar id is usually its owner's address). */ function providerEndpoint(input: RequestInfo | URL): string | undefined { try { const url = new URL(input instanceof Request ? input.url : input) - return `${url.host}${url.pathname.replace(/\([^/]*\)/g, '()')}` + const path = url.pathname + .replace(/\([^/]*\)/g, '()') + .replace(/[^/]*(?:@|%40)[^/]*/gi, '{redacted}') + return `${url.host}${path}` } catch { return undefined } diff --git a/apps/sim/lib/table/application/rows.ts b/apps/sim/lib/table/application/rows.ts index d955d5744c4..11df8778be5 100644 --- a/apps/sim/lib/table/application/rows.ts +++ b/apps/sim/lib/table/application/rows.ts @@ -506,9 +506,10 @@ async function throwValidationResponse( } /** - * A structured row query cancelled by its own `statement_timeout`: the query shape costs more than - * the table's size allows. Sorting or range-filtering on a stored column reads every row's data, so it is the - * caller's to narrow, and retrying the same query only times out again. + * A structured row query cancelled by its own `statement_timeout`. Sorting or range-filtering on a + * stored column reads every candidate row's full data, so on a large table the query only finishes + * when that data happens to be cached. The fix is the caller's (narrow the query), so this is a 400 + * with an actionable message rather than an opaque 500. */ function rethrowReadQueryError(error: unknown, tableId: string): never { if (getPostgresCancellationReason(error) === 'statement_timeout') {