Skip to content

Commit ae8785b

Browse files
committed
fix(copilot): capture prompt telemetry only once the turn is allowed to run
1 parent 66056ad commit ae8785b

3 files changed

Lines changed: 92 additions & 12 deletions

File tree

apps/sim/lib/copilot/chat/post.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,41 @@ const {
6161
releaseChatSendClaim: vi.fn(),
6262
}))
6363

64+
/**
65+
* The root span, captured so a test can assert what a refused turn exported.
66+
* `withCopilotSpan` is a pass-through here — the nesting it provides is not
67+
* under test and a real tracer would need an exporter to observe.
68+
*/
69+
const { setInputMessages, setUserMessagePreview, startCopilotOtelRoot } = vi.hoisted(() => ({
70+
setInputMessages: vi.fn(),
71+
setUserMessagePreview: vi.fn(),
72+
startCopilotOtelRoot: vi.fn(),
73+
}))
74+
75+
vi.mock('@/lib/copilot/request/otel', async () => {
76+
const { ROOT_CONTEXT, trace } = await import('@opentelemetry/api')
77+
const span = () => trace.getTracer('post-test').startSpan('post-test')
78+
startCopilotOtelRoot.mockImplementation(() => ({
79+
span: span(),
80+
context: ROOT_CONTEXT,
81+
requestId: 'req-1',
82+
finish: vi.fn(),
83+
setUserMessagePreview,
84+
setInputMessages,
85+
setOutputMessages: vi.fn(),
86+
setRequestShape: vi.fn(),
87+
}))
88+
return {
89+
startCopilotOtelRoot,
90+
withCopilotSpan: (
91+
_name: string,
92+
_attrs: Record<string, unknown> | undefined,
93+
fn: (child: ReturnType<typeof span>) => unknown,
94+
_context?: unknown
95+
) => fn(span()),
96+
}
97+
})
98+
6499
const resolvePermissionGroupConfig = permissionGroupScopeMockFns.mockResolvePermissionGroupConfig
65100

66101
const getSession = authMockFns.mockGetSession
@@ -1068,6 +1103,38 @@ describe('handleUnifiedChatPost copilot.use capability gate', () => {
10681103
expect(createSSEStream).toHaveBeenCalledTimes(1)
10691104
})
10701105

1106+
/**
1107+
* Prompt content is exported only once the turn is going to run. GenAI
1108+
* message capture is gated on whether capture is enabled at all, not on
1109+
* whether this caller may send, so capturing at span start exported the
1110+
* message of every turn the gate then refused.
1111+
*/
1112+
it('exports no part of the prompt when the send is refused', async () => {
1113+
resolvePermissionGroupConfig.mockResolvedValue({
1114+
...DEFAULT_PERMISSION_GROUP_CONFIG,
1115+
hideCopilot: true,
1116+
})
1117+
1118+
const response = await handleUnifiedChatPost(chatRequest({ createNewChat: true }))
1119+
1120+
expect(response.status).toBe(403)
1121+
expect(setInputMessages).not.toHaveBeenCalled()
1122+
expect(setUserMessagePreview).not.toHaveBeenCalled()
1123+
expect(startCopilotOtelRoot).toHaveBeenCalledWith(
1124+
expect.not.objectContaining({ userMessagePreview: expect.anything() })
1125+
)
1126+
})
1127+
1128+
it('captures the prompt once the send is allowed to run', async () => {
1129+
resolvePermissionGroupConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG)
1130+
1131+
const response = await handleUnifiedChatPost(chatRequest({ createNewChat: true }))
1132+
1133+
expect(response.status).toBe(200)
1134+
expect(setUserMessagePreview).toHaveBeenCalledWith('Hello')
1135+
expect(setInputMessages).toHaveBeenCalledWith({ userMessage: 'Hello' })
1136+
})
1137+
10711138
/** A branch that lands in no workspace at all is governed by no group. */
10721139
it('does not consult a permission group when the branch resolves no workspace', async () => {
10731140
resolveWorkflowIdForUser.mockResolvedValue({

apps/sim/lib/copilot/chat/post.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,7 +1073,6 @@ export async function handleUnifiedChatPost(req: NextRequest) {
10731073
executionId,
10741074
runId,
10751075
transport: CopilotTransport.Stream,
1076-
userMessagePreview: body.message,
10771076
})
10781077
if (otelRoot.requestId) {
10791078
requestId = otelRoot.requestId
@@ -1088,10 +1087,6 @@ export async function handleUnifiedChatPost(req: NextRequest) {
10881087
if (authenticatedUserEmail) {
10891088
otelRoot.span.setAttribute(TraceAttr.UserEmail, authenticatedUserEmail)
10901089
}
1091-
// `setInputMessages` is internally gated on
1092-
// OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT; safe to call.
1093-
otelRoot.setInputMessages({ userMessage: body.message })
1094-
10951090
// Wrap the rest of the handler so nested spans attach to the
10961091
// root via AsyncLocalStorage (otherwise they orphan into new traces).
10971092
const activeOtelRoot = otelRoot
@@ -1160,6 +1155,17 @@ export async function handleUnifiedChatPost(req: NextRequest) {
11601155
return capabilityRefusalResponse(chatCapability)
11611156
}
11621157

1158+
/* Prompt content is captured only once the turn is going to run. Both
1159+
calls are internally gated on
1160+
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, but the gate is on
1161+
whether capture is enabled at all, not on whether this caller may send
1162+
— so stamping them at span start exported the message of every turn the
1163+
capability check above then refused. Every refusal ahead of this point
1164+
(a rejected branch, a withheld `copilot.use`) now records the shape of
1165+
the request and none of its content. */
1166+
activeOtelRoot.setUserMessagePreview(body.message)
1167+
activeOtelRoot.setInputMessages({ userMessage: body.message })
1168+
11631169
let currentChat: ChatLoadResult['chat'] = null
11641170
let conversationHistory: unknown[] = []
11651171
let chatIsNew = false

apps/sim/lib/copilot/request/otel.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -360,19 +360,13 @@ interface CopilotOtelScope {
360360
runId?: string
361361
streamId?: string
362362
transport: 'headless' | 'stream'
363-
userMessagePreview?: string
364363
}
365364

366365
// Dashboard-column width; long enough for triage disambiguation.
367366
const USER_MESSAGE_PREVIEW_MAX_CHARS = 500
368367
function buildAgentSpanAttributes(
369368
scope: CopilotOtelScope & { requestId: string }
370369
): Record<string, string | number | boolean> {
371-
// Gated behind the same env var as full GenAI message capture — a
372-
// 500-char preview is still user prompt content.
373-
const preview = isGenAIMessageCaptureEnabled()
374-
? truncateUserMessagePreview(scope.userMessagePreview)
375-
: undefined
376370
return {
377371
[TraceAttr.GenAiAgentName]: 'mothership',
378372
[TraceAttr.GenAiAgentId]:
@@ -388,7 +382,6 @@ function buildAgentSpanAttributes(
388382
...(scope.executionId ? { [TraceAttr.CopilotExecutionId]: scope.executionId } : {}),
389383
...(scope.runId ? { [TraceAttr.RunId]: scope.runId } : {}),
390384
...(scope.streamId ? { [TraceAttr.StreamId]: scope.streamId } : {}),
391-
...(preview ? { [TraceAttr.CopilotUserMessagePreview]: preview } : {}),
392385
}
393386
}
394387

@@ -432,6 +425,15 @@ interface CopilotOtelRoot {
432425
error?: unknown,
433426
cancelReason?: CopilotRequestCancelReasonValue
434427
) => void
428+
/**
429+
* Stamp the triage preview of the user's prompt.
430+
*
431+
* Gated behind the same env var as full GenAI message capture — a 500-char
432+
* preview is still user prompt content — and separate from span creation so
433+
* that a turn refused before it starts (a capability the caller's permission
434+
* group withholds, a rejected branch) exports no part of the prompt.
435+
*/
436+
setUserMessagePreview: (raw: string | undefined) => void
435437
setInputMessages: (input: CopilotAgentInputMessages) => void
436438
setOutputMessages: (output: CopilotAgentOutputMessages) => void
437439
setRequestShape: (shape: CopilotOtelRequestShape) => void
@@ -503,6 +505,11 @@ export function startCopilotOtelRoot(
503505
context: rootContext,
504506
requestId,
505507
finish,
508+
setUserMessagePreview: (raw) => {
509+
if (!isGenAIMessageCaptureEnabled()) return
510+
const preview = truncateUserMessagePreview(raw)
511+
if (preview) span.setAttribute(TraceAttr.CopilotUserMessagePreview, preview)
512+
},
506513
setInputMessages: (input) => setAgentInputMessages(span, input),
507514
setOutputMessages: (output) => setAgentOutputMessages(span, output),
508515
setRequestShape: (shape) => applyRequestShape(span, shape),

0 commit comments

Comments
 (0)