Skip to content

Commit 5f2b8cd

Browse files
authored
fix(search): trace live provider latency and partial failures (#9482)
* fix(search): trace live provider latency and partial failures * fix(search): use the shared deferred test helper * fix(search): report every supported date filter in diagnostics
1 parent 397ea37 commit 5f2b8cd

11 files changed

Lines changed: 690 additions & 146 deletions

File tree

‎apps/sim/app/api/knowledge/search/route.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ export const POST = defineInternalJsonRoute({
1717
'Provider limits apply independently to each user grant; requests have bounded fanout and deadlines',
1818
}),
1919
errorPolicy: internalKnowledgeErrorPolicies.search,
20-
mapInput: ({ body }, { request }) => ({ ...body, signal: request.signal }),
20+
mapInput: ({ body }, { request }) => ({
21+
...body,
22+
signal: request.signal,
23+
surface: 'dashboard' as const,
24+
}),
2125
useCase: searchLiveKnowledge,
2226
present: (data) => ({ success: true as const, data }),
2327
})

‎apps/sim/lib/knowledge/application/search-diagnostics.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@ export function instrumentSearchUseCase<
3333
topK: args.input.topK,
3434
documentFilterCount: args.input.filters?.documentIds?.length ?? 0,
3535
hasSourceFilter: Boolean(args.input.filters?.source),
36-
hasDateFilter: Boolean(args.input.filters?.modifiedAfter),
36+
hasDateFilter: Boolean(
37+
args.input.filters?.modifiedAfter ||
38+
args.input.filters?.modifiedBefore ||
39+
args.input.filters?.startDate ||
40+
args.input.filters?.endDate
41+
),
3742
tagFilterCount: args.input.tagFilters?.length ?? 0,
3843
hasProvenance: Boolean(
3944
args.input.resultSecretRegistry || args.input.prepareModelInputProvenance

‎apps/sim/lib/knowledge/mcp/server.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe
139139
const result = await searchLiveKnowledge.execute({
140140
principal,
141141
input: {
142+
surface: 'mcp',
142143
organizationId,
143144
query,
144145
topK: topK ?? liveSearchMcpDefaultTopK(parsed),
Lines changed: 143 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
import { createDeferred } from '@sim/testing/helpers'
12
import { getMockLogger } from '@sim/testing/mocks/logger.mock'
23
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3-
import { measureSearchStage, withSearchDiagnostics } from '@/lib/knowledge/search/diagnostics'
4+
import {
5+
measureSearchStage,
6+
recordSearchProviderResult,
7+
withSearchDiagnostics,
8+
withSearchProviderDiagnostics,
9+
withSearchRequestDiagnostics,
10+
} from '@/lib/knowledge/search/diagnostics'
411

512
const logs = getMockLogger('KnowledgeSearchDiagnostics')
613

@@ -9,21 +16,13 @@ beforeEach(() => {
916
})
1017
afterEach(() => vi.useRealTimers())
1118

12-
function _deferred() {
13-
let resolve!: () => void
14-
const promise = new Promise<void>((done) => {
15-
resolve = done
16-
})
17-
return { promise, resolve }
18-
}
19-
2019
describe('search pipeline diagnostics', () => {
2120
it('preserves failures, records repeated stage errors, and does not log error content', async () => {
2221
const error = new Error('private provider response')
2322
await expect(
2423
withSearchDiagnostics({ surface: 'copilot' }, async () => {
25-
await measureSearchStage('vector.authorization', () => 'allowed')
26-
return measureSearchStage('vector.authorization', () => {
24+
await measureSearchStage('live.resolve', () => 'allowed')
25+
return measureSearchStage('live.resolve', () => {
2726
throw error
2827
})
2928
})
@@ -33,11 +32,143 @@ describe('search pipeline diagnostics', () => {
3332
expect.objectContaining({
3433
outcome: 'error',
3534
stages: {
36-
'vector.authorization': { count: 2, totalMs: 0, maxMs: 0, errors: 1 },
35+
'live.resolve': { count: 2, totalMs: 0, maxMs: 0, errors: 1 },
3736
},
3837
})
3938
)
4039
expect(JSON.stringify(logs.info.mock.calls)).not.toContain(error.message)
4140
expect(vi.getTimerCount()).toBe(0)
4241
})
42+
43+
it('isolates concurrent provider failures and retains errors caught by a search adapter', async () => {
44+
const error = Object.assign(new Error('private query, provider response, and credential'), {
45+
code: 'private credential',
46+
httpStatus: 403,
47+
diagnosticCode: 'provider_access_denied',
48+
})
49+
const result = await withSearchDiagnostics({ surface: 'dashboard' }, () =>
50+
Promise.all([
51+
withSearchProviderDiagnostics({ provider: 'gmail', accountIndex: 0 }, async () => {
52+
await measureSearchStage('live.search', () =>
53+
withSearchRequestDiagnostics({ transport: 'native_http', method: 'GET' }, async () => {
54+
await Promise.resolve()
55+
throw error
56+
}).catch(() => undefined)
57+
)
58+
recordSearchProviderResult({ status: 'partial', resultCount: 1 })
59+
return ['available result']
60+
}),
61+
withSearchProviderDiagnostics({ provider: 'github', accountIndex: 1 }, async () => {
62+
await measureSearchStage('live.verify', () =>
63+
withSearchRequestDiagnostics(
64+
{ transport: 'native_http', method: 'GET' },
65+
async (record) => {
66+
record({ httpStatus: 200 })
67+
}
68+
)
69+
)
70+
recordSearchProviderResult({ status: 'ok', resultCount: 2 })
71+
return ['other result']
72+
}),
73+
])
74+
)
75+
expect(result).toEqual([['available result'], ['other result']])
76+
const summaries = logs.info.mock.calls
77+
.filter(([message]) => message === 'Live search provider completed')
78+
.map(([, data]) => data)
79+
expect(summaries).toEqual(
80+
expect.arrayContaining([
81+
expect.objectContaining({
82+
provider: 'gmail',
83+
requests: [
84+
expect.objectContaining({
85+
stage: 'live.search',
86+
outcome: 'error',
87+
failure: {
88+
reason: 'provider_access_denied',
89+
httpStatus: 403,
90+
},
91+
}),
92+
],
93+
results: [{ status: 'partial', resultCount: 1 }],
94+
}),
95+
expect.objectContaining({
96+
provider: 'github',
97+
requests: [
98+
expect.objectContaining({ stage: 'live.verify', outcome: 'success', httpStatus: 200 }),
99+
],
100+
results: [{ status: 'ok', resultCount: 2 }],
101+
}),
102+
])
103+
)
104+
const serialized = JSON.stringify(logs.info.mock.calls)
105+
expect(serialized).not.toContain('private')
106+
expect(serialized).not.toContain('available result')
107+
expect(serialized).not.toContain('other result')
108+
expect(new Set(summaries.map((summary) => summary.searchId)).size).toBe(1)
109+
})
110+
111+
it('preserves cancellation and bounds request diagnostics without losing failure counts', async () => {
112+
const aborted = new DOMException('private cancellation reason', 'AbortError')
113+
await expect(
114+
withSearchDiagnostics({}, () =>
115+
withSearchProviderDiagnostics({ provider: 'gmail', accountIndex: 0 }, async () => {
116+
for (let index = 0; index < 140; index++) {
117+
await withSearchRequestDiagnostics(
118+
{ transport: 'native_http', method: 'GET' },
119+
async () => {
120+
throw Object.assign(new Error('private failure'), { code: 'ECONNRESET' })
121+
}
122+
).catch(() => undefined)
123+
}
124+
return withSearchRequestDiagnostics(
125+
{ transport: 'native_http', method: 'GET' },
126+
async () => {
127+
throw aborted
128+
}
129+
)
130+
})
131+
)
132+
).rejects.toBe(aborted)
133+
expect(logs.info).toHaveBeenCalledWith(
134+
'Live search provider completed',
135+
expect.objectContaining({
136+
requestCount: 141,
137+
failedRequestCount: 141,
138+
omittedRequestCount: 13,
139+
outcome: 'error',
140+
failure: { reason: 'aborted' },
141+
})
142+
)
143+
expect(JSON.stringify(logs.info.mock.calls)).not.toContain('private')
144+
expect(vi.getTimerCount()).toBe(0)
145+
})
146+
147+
it('records preview failures that settle after the search has returned', async () => {
148+
const pending = createDeferred<void>()
149+
let request: Promise<unknown> | undefined
150+
await withSearchDiagnostics({}, () =>
151+
withSearchProviderDiagnostics({ provider: 'gmail', accountIndex: 0 }, async () => {
152+
request = withSearchRequestDiagnostics(
153+
{ transport: 'native_http', method: 'GET' },
154+
() => pending.promise
155+
).catch(() => undefined)
156+
})
157+
)
158+
expect(logs.info).toHaveBeenCalledWith(
159+
'Live search provider completed',
160+
expect.objectContaining({ pendingRequestCount: 1 })
161+
)
162+
pending.reject(new DOMException('private preview content', 'AbortError'))
163+
await request
164+
expect(logs.info).toHaveBeenLastCalledWith(
165+
'Live search request completed after provider',
166+
expect.objectContaining({
167+
requestIndex: 0,
168+
outcome: 'error',
169+
failure: { reason: 'aborted' },
170+
})
171+
)
172+
expect(JSON.stringify(logs.info.mock.calls)).not.toContain('private preview content')
173+
})
43174
})

0 commit comments

Comments
 (0)