Skip to content

Commit 13949ec

Browse files
authored
improvement(search): log the shape of each search and the source, kind, and size of each read (#10042)
* improvement(search): log the shape of each search and the source, kind, and size of each read * improvement(search): log a document kind only as a type name or MIME type
1 parent e4d8673 commit 13949ec

10 files changed

Lines changed: 496 additions & 72 deletions

File tree

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

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,18 @@ import type { knowledgeOperations } from '@/lib/knowledge/application/operations
33
import type { SearchKnowledgeInput } from '@/lib/knowledge/application/search'
44
import {
55
measureSearchStage,
6+
type SearchDiagnosticMetadata,
67
type SearchStage,
78
withSearchDiagnostics,
89
} from '@/lib/knowledge/search/diagnostics'
910

11+
function scopeKind(input: {
12+
workspaceId?: string | null
13+
organizationId?: string | null
14+
}): SearchDiagnosticMetadata['scopeKind'] {
15+
return input.organizationId ? 'organization' : input.workspaceId ? 'workspace' : undefined
16+
}
17+
1018
/** Preserve the operation and authorization contract while timing the whole use case. */
1119
export function instrumentSearchUseCase<
1220
I extends Omit<SearchKnowledgeInput, 'knowledgeBaseIds' | 'workspaceId' | 'organizationId'> & {
@@ -25,11 +33,7 @@ export function instrumentSearchUseCase<
2533
{
2634
surface: args.input.surface ?? 'other',
2735
principalKind: args.principal.kind,
28-
scopeKind: args.input.organizationId
29-
? 'organization'
30-
: args.input.workspaceId
31-
? 'workspace'
32-
: undefined,
36+
scopeKind: scopeKind(args.input),
3337
topK: args.input.topK,
3438
documentFilterCount: args.input.filters?.documentIds?.length ?? 0,
3539
hasSourceFilter: Boolean(args.input.filters?.source),
@@ -48,3 +52,35 @@ export function instrumentSearchUseCase<
4852
),
4953
}
5054
}
55+
56+
/**
57+
* Times a document read as one trace, so each read logs its own summary: the source, the
58+
* document's kind and size, and the window returned. A read inside a traced tool call adds to
59+
* that call's trace instead.
60+
*/
61+
export function instrumentReadUseCase<
62+
I extends {
63+
surface?: SearchDiagnosticMetadata['surface']
64+
workspaceId?: string | null
65+
organizationId?: string | null
66+
startOffset?: number
67+
},
68+
R,
69+
>(
70+
useCase: AuthorizingUseCase<typeof knowledgeOperations.readDocument, I, R>
71+
): AuthorizingUseCase<typeof knowledgeOperations.readDocument, I, R> {
72+
return {
73+
...useCase,
74+
execute: (args) =>
75+
withSearchDiagnostics(
76+
{
77+
operation: 'read_document',
78+
...(args.input.surface ? { surface: args.input.surface } : {}),
79+
principalKind: args.principal.kind,
80+
scopeKind: scopeKind(args.input),
81+
continued: Boolean(args.input.startOffset),
82+
},
83+
() => measureSearchStage('document_read', () => useCase.execute(args))
84+
),
85+
}
86+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe
297297
principal,
298298
input: {
299299
...options,
300+
surface: 'mcp',
300301
documentId: id,
301302
organizationId,
302303
scaledWindow: true,

‎apps/sim/lib/knowledge/search/diagnostics.test.ts‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import { createDeferred } from '@sim/testing/helpers'
22
import { getMockLogger } from '@sim/testing/mocks/logger.mock'
33
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { OrchestrationError } from '@/lib/core/orchestration/types'
45
import {
6+
describeSearchRequest,
7+
documentKindLabel,
58
measureSearchStage,
69
recordSearchProviderResult,
710
withSearchDiagnostics,
811
withSearchProviderDiagnostics,
912
withSearchRequestDiagnostics,
1013
} from '@/lib/knowledge/search/diagnostics'
14+
import { LiveReadError } from '@/lib/sim-search/live/read-error'
1115

1216
const logs = getMockLogger('KnowledgeSearchDiagnostics')
1317

@@ -171,4 +175,112 @@ describe('search pipeline diagnostics', () => {
171175
)
172176
expect(JSON.stringify(logs.info.mock.calls)).not.toContain('private preview content')
173177
})
178+
179+
it('describes a search request without its text, modifiers, cursor, or account', () => {
180+
const shape = describeSearchRequest({
181+
query: ' private launch plan ',
182+
filters: {
183+
startDate: '2026-10-30T00:00:00Z',
184+
endDate: '2026-11-13T12:00:00Z',
185+
sortBy: 'newest',
186+
},
187+
nativeQueries: [
188+
{
189+
provider: 'slack',
190+
query: 'private decision',
191+
modifiers: 'in:#private-channel',
192+
cursor: 'private-cursor',
193+
accountId: 'private-account',
194+
termClauses: ['private term'],
195+
},
196+
{ provider: 'jira', query: '', kind: 'issues' },
197+
],
198+
})
199+
expect(shape).toEqual({
200+
queryTermCount: 3,
201+
sortBy: 'newest',
202+
dateWindowDays: 14.5,
203+
nativeQueries: [
204+
{
205+
provider: 'slack',
206+
hasText: true,
207+
hasModifiers: true,
208+
hasCursor: true,
209+
hasProject: false,
210+
targetsAccount: true,
211+
keywordOnly: false,
212+
termClauseCount: 1,
213+
},
214+
{
215+
provider: 'jira',
216+
kind: 'issues',
217+
hasText: false,
218+
hasModifiers: false,
219+
hasCursor: false,
220+
hasProject: false,
221+
targetsAccount: false,
222+
keywordOnly: false,
223+
termClauseCount: 0,
224+
},
225+
],
226+
})
227+
expect(JSON.stringify(shape)).not.toContain('private')
228+
})
229+
230+
it('keeps a document kind only when it is a bounded token', () => {
231+
expect(documentKindLabel('file')).toBe('file')
232+
expect(documentKindLabel('application/vnd.google-apps.document')).toBe(
233+
'application/vnd.google-apps.document'
234+
)
235+
expect(documentKindLabel('Quarterly plan for a private customer')).toBe('other')
236+
expect(documentKindLabel('ProjectAtlas_Q3')).toBe('other')
237+
expect(documentKindLabel('atlas-launch-notes')).toBe('other')
238+
expect(documentKindLabel('notes/atlas')).toBe('other')
239+
expect(documentKindLabel(undefined)).toBeUndefined()
240+
})
241+
242+
it('names the class of a failure that ends a trace, never its message', async () => {
243+
const failures = [
244+
new OrchestrationError('not_found', 'private document title is gone'),
245+
new LiveReadError('private provider text', true),
246+
new Error('private unexpected text'),
247+
]
248+
for (const error of failures)
249+
await expect(
250+
withSearchDiagnostics({ operation: 'read_document' }, async () => {
251+
throw error
252+
})
253+
).rejects.toBe(error)
254+
const summaries = logs.info.mock.calls
255+
.filter(([message]) => message === 'Knowledge search completed')
256+
.slice(-3)
257+
.map(([, fields]) => fields?.failure)
258+
expect(summaries).toEqual([
259+
{ reason: 'not_found' },
260+
{ reason: 'provider_retryable' },
261+
{ reason: 'unexpected_error' },
262+
])
263+
const logged = JSON.stringify(logs.info.mock.calls)
264+
for (const error of failures) expect(logged).not.toContain(error.message)
265+
})
266+
267+
it('keeps the failure class when an outer tool call turns the error into a result', async () => {
268+
await withSearchDiagnostics({ surface: 'copilot' }, async () => {
269+
try {
270+
await withSearchDiagnostics({ operation: 'read_document' }, async () => {
271+
throw new OrchestrationError('not_found', 'private document title is gone')
272+
})
273+
} catch {
274+
return { success: false }
275+
}
276+
})
277+
expect(logs.info).toHaveBeenLastCalledWith(
278+
'Knowledge search completed',
279+
expect.objectContaining({
280+
surface: 'copilot',
281+
outcome: 'error',
282+
failure: { reason: 'not_found' },
283+
})
284+
)
285+
})
174286
})

0 commit comments

Comments
 (0)