Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ba8a7ef
Add AskAI message feedback endpoint
reakaleek Dec 1, 2025
4ce3f48
Adjust otel naming
reakaleek Dec 1, 2025
1e52f45
Add UI onclick handler and other adjustments
reakaleek Dec 1, 2025
264ac88
Add log sanitization
reakaleek Dec 1, 2025
2d08faa
Use spans and create tests
reakaleek Dec 1, 2025
25eb256
Remove all control characters as suggested by CodeQL
reakaleek Dec 1, 2025
7926de7
Validate input
reakaleek Dec 1, 2025
4df1e5c
Refactor
reakaleek Dec 1, 2025
d0883cf
Persist reaction selection
reakaleek Dec 1, 2025
3dadebf
Add debounce if user quickly changes selection
reakaleek Dec 1, 2025
b48be34
Fix other CodeQL errors regarind log sanitization
reakaleek Dec 1, 2025
c2b7c3c
Fix formatting
reakaleek Dec 1, 2025
79e4117
Fix test
reakaleek Dec 1, 2025
5de5770
Dispose correctly
reakaleek Dec 1, 2025
a112b98
Change scope of services
reakaleek Dec 1, 2025
6eea0cb
Potential fix for pull request finding 'Missing Dispose call on local…
reakaleek Dec 1, 2025
b6b2cbf
Potential fix for pull request finding 'Missing Dispose call on local…
reakaleek Dec 1, 2025
5f6a852
Format
reakaleek Dec 1, 2025
07ff6e6
Fix logging format for conversation_id in AgentBuilder
reakaleek Dec 1, 2025
01cd487
Fix tests
reakaleek Dec 1, 2025
ce47e59
Potential fix for code scanning alert no. 38: Log entries created fro…
reakaleek Dec 1, 2025
e562c5c
Re-add fast path for common cases to avoid string allocation
reakaleek Dec 1, 2025
d313731
Potential fix for code scanning alert no. 35: Log entries created fro…
reakaleek Dec 1, 2025
ce6d0ea
Potential fix for code scanning alert no. 39: Log entries created fro…
reakaleek Dec 1, 2025
7dca09e
Potential fix for code scanning alert no. 43: Log entries created fro…
reakaleek Dec 1, 2025
65ad494
Also remove newlines
reakaleek Dec 1, 2025
f622191
Remove user input from logs
reakaleek Dec 1, 2025
717d3d2
Remove this troublesome log for now
reakaleek Dec 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { ApiError } from '../errorHandling'
import { AskAiEvent, ChunkEvent, EventTypes } from './AskAiEvent'
import { GeneratingStatus } from './GeneratingStatus'
import { References } from './RelatedResources'
import { ChatMessage as ChatMessageType } from './chat.store'
import { ChatMessage as ChatMessageType, useConversationId } from './chat.store'
import { useMessageFeedback, Reaction } from './useMessageFeedback'
import { useStatusMinDisplay } from './useStatusMinDisplay'
import {
EuiButtonIcon,
Expand Down Expand Up @@ -186,62 +187,86 @@ const computeAiStatus = (
// Action bar for complete AI messages
const ActionBar = ({
content,
messageId,
onRetry,
}: {
content: string
messageId: string
onRetry?: () => void
}) => (
<EuiFlexGroup responsive={false} component="span" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiToolTip content="This answer was helpful">
<EuiButtonIcon
aria-label="This answer was helpful"
iconType="thumbUp"
color="success"
size="s"
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content="This answer was not helpful">
<EuiButtonIcon
aria-label="This answer was not helpful"
iconType="thumbDown"
color="danger"
size="s"
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiCopy
textToCopy={content}
beforeMessage="Copy markdown"
afterMessage="Copied!"
>
{(copy) => (
}) => {
const conversationId = useConversationId()
const { selectedReaction, submitFeedback, isPending } = useMessageFeedback(
messageId,
conversationId
)

const handleFeedback = (reaction: Reaction) => {
if (!isPending) {
submitFeedback(reaction)
}
}

return (
<EuiFlexGroup responsive={false} component="span" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiToolTip content="This answer was helpful">
<EuiButtonIcon
aria-label="Copy markdown"
iconType="copy"
aria-label="This answer was helpful"
iconType="thumbUp"
color="success"
size="s"
onClick={copy}
display={
selectedReaction === 'thumbsUp' ? 'base' : 'empty'
}
onClick={() => handleFeedback('thumbsUp')}
/>
)}
</EuiCopy>
</EuiFlexItem>
{onRetry && (
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content="Request a new answer">
<EuiToolTip content="This answer was not helpful">
<EuiButtonIcon
aria-label="Request a new answer"
iconType="refresh"
onClick={onRetry}
aria-label="This answer was not helpful"
iconType="thumbDown"
color="danger"
size="s"
display={
selectedReaction === 'thumbsDown' ? 'base' : 'empty'
}
onClick={() => handleFeedback('thumbsDown')}
/>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
)
<EuiFlexItem grow={false}>
<EuiCopy
textToCopy={content}
beforeMessage="Copy markdown"
afterMessage="Copied!"
>
{(copy) => (
<EuiButtonIcon
aria-label="Copy markdown"
iconType="copy"
size="s"
onClick={copy}
/>
)}
</EuiCopy>
</EuiFlexItem>
{onRetry && (
<EuiFlexItem grow={false}>
<EuiToolTip content="Request a new answer">
<EuiButtonIcon
aria-label="Request a new answer"
iconType="refresh"
onClick={onRetry}
size="s"
/>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
)
}

export const ChatMessage = ({
message,
Expand Down Expand Up @@ -412,6 +437,7 @@ export const ChatMessage = ({
<EuiSpacer size="m" />
<ActionBar
content={mainContent}
messageId={message.id}
onRetry={onRetry}
/>
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { logWarn } from '../../../telemetry/logging'
import { traceSpan } from '../../../telemetry/tracing'
import { useMutation } from '@tanstack/react-query'
import { useState, useCallback } from 'react'

export type Reaction = 'thumbsUp' | 'thumbsDown'

interface MessageFeedbackRequest {
messageId: string
conversationId: string
reaction: Reaction
}

interface UseMessageFeedbackReturn {
selectedReaction: Reaction | null
submitFeedback: (reaction: Reaction) => void
isPending: boolean
}

const submitFeedbackToApi = async (
payload: MessageFeedbackRequest
): Promise<void> => {
await traceSpan('submit message-feedback', async (span) => {
span.setAttribute('gen_ai.conversation.id', payload.conversationId) // correlation with backend
span.setAttribute('ask_ai.message.id', payload.messageId)
span.setAttribute('ask_ai.feedback.reaction', payload.reaction)

const response = await fetch('/docs/_api/v1/ask-ai/message-feedback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})

if (!response.ok) {
logWarn('Failed to submit feedback', {
'http.status_code': response.status,
'ask_ai.message.id': payload.messageId,
'ask_ai.feedback.reaction': payload.reaction,
})
}
})
}

export const useMessageFeedback = (
messageId: string,
conversationId: string | null
): UseMessageFeedbackReturn => {
const [selectedReaction, setSelectedReaction] = useState<Reaction | null>(
null
)

const mutation = useMutation({

Check failure on line 54 in src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts

View workflow job for this annotation

GitHub Actions / npm

ChatMessage Component › Markdown rendering › should render markdown content

No QueryClient set, use QueryClientProvider to set one at useQueryClient (node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:11) at useMutation (node_modules/@tanstack/react-query/src/useMutation.ts:28:18) at useMessageFeedback (Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts:54:33) at ActionBar (Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx:198:79) at Component (node_modules/react-dom/cjs/react-dom.development.js:15486:18) at renderWithHooks (node_modules/react-dom/cjs/react-dom.development.js:20103:13) at mountIndeterminateComponent (node_modules/react-dom/cjs/react-dom.development.js:21626:16) at beginWork (node_modules/react-dom/cjs/react-dom.development.js:27465:14) at beginWork$1 (node_modules/react-dom/cjs/react-dom.development.js:26599:12) at performUnitOfWork (node_modules/react-dom/cjs/react-dom.development.js:26505:5) at workLoopSync (node_modules/react-dom/cjs/react-dom.development.js:26473:7) at renderRootSync (node_modules/react-dom/cjs/react-dom.development.js:25889:20) at recoverFromConcurrentError (node_modules/react-dom/cjs/react-dom.development.js:25789:22) at callback (node_modules/react/cjs/react.development.js:2667:24) at flushActQueue (node_modules/react/cjs/react.development.js:2582:11) at actImplementation (node_modules/@testing-library/react/dist/act-compat.js:47:25) at renderRoot (node_modules/@testing-library/react/dist/pure.js:190:25) at renderRoot (node_modules/@testing-library/react/dist/pure.js:292:10) at Object.<anonymous> (Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.test.tsx:285:19)

Check failure on line 54 in src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts

View workflow job for this annotation

GitHub Actions / npm

ChatMessage Component › AI messages - complete › should display Elastic logo icon

No QueryClient set, use QueryClientProvider to set one at useQueryClient (node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:11) at useMutation (node_modules/@tanstack/react-query/src/useMutation.ts:28:18) at useMessageFeedback (Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts:54:33) at ActionBar (Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx:198:79) at Component (node_modules/react-dom/cjs/react-dom.development.js:15486:18) at renderWithHooks (node_modules/react-dom/cjs/react-dom.development.js:20103:13) at mountIndeterminateComponent (node_modules/react-dom/cjs/react-dom.development.js:21626:16) at beginWork (node_modules/react-dom/cjs/react-dom.development.js:27465:14) at beginWork$1 (node_modules/react-dom/cjs/react-dom.development.js:26599:12) at performUnitOfWork (node_modules/react-dom/cjs/react-dom.development.js:26505:5) at workLoopSync (node_modules/react-dom/cjs/react-dom.development.js:26473:7) at renderRootSync (node_modules/react-dom/cjs/react-dom.development.js:25889:20) at recoverFromConcurrentError (node_modules/react-dom/cjs/react-dom.development.js:25789:22) at callback (node_modules/react/cjs/react.development.js:2667:24) at flushActQueue (node_modules/react/cjs/react.development.js:2582:11) at actImplementation (node_modules/@testing-library/react/dist/act-compat.js:47:25) at renderRoot (node_modules/@testing-library/react/dist/pure.js:190:25) at renderRoot (node_modules/@testing-library/react/dist/pure.js:292:10) at Object.<anonymous> (Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.test.tsx:173:19)

Check failure on line 54 in src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts

View workflow job for this annotation

GitHub Actions / npm

ChatMessage Component › AI messages - complete › should show feedback buttons

No QueryClient set, use QueryClientProvider to set one at useQueryClient (node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:11) at useMutation (node_modules/@tanstack/react-query/src/useMutation.ts:28:18) at useMessageFeedback (Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts:54:33) at ActionBar (Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx:198:79) at Component (node_modules/react-dom/cjs/react-dom.development.js:15486:18) at renderWithHooks (node_modules/react-dom/cjs/react-dom.development.js:20103:13) at mountIndeterminateComponent (node_modules/react-dom/cjs/react-dom.development.js:21626:16) at beginWork (node_modules/react-dom/cjs/react-dom.development.js:27465:14) at beginWork$1 (node_modules/react-dom/cjs/react-dom.development.js:26599:12) at performUnitOfWork (node_modules/react-dom/cjs/react-dom.development.js:26505:5) at workLoopSync (node_modules/react-dom/cjs/react-dom.development.js:26473:7) at renderRootSync (node_modules/react-dom/cjs/react-dom.development.js:25889:20) at recoverFromConcurrentError (node_modules/react-dom/cjs/react-dom.development.js:25789:22) at callback (node_modules/react/cjs/react.development.js:2667:24) at flushActQueue (node_modules/react/cjs/react.development.js:2582:11) at actImplementation (node_modules/@testing-library/react/dist/act-compat.js:47:25) at renderRoot (node_modules/@testing-library/react/dist/pure.js:190:25) at renderRoot (node_modules/@testing-library/react/dist/pure.js:292:10) at Object.<anonymous> (Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.test.tsx:156:19)

Check failure on line 54 in src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts

View workflow job for this annotation

GitHub Actions / npm

ChatMessage Component › AI messages - complete › should render AI message with correct content

No QueryClient set, use QueryClientProvider to set one at useQueryClient (node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:11) at useMutation (node_modules/@tanstack/react-query/src/useMutation.ts:28:18) at useMessageFeedback (Assets/web-components/SearchOrAskAi/AskAi/useMessageFeedback.ts:54:33) at ActionBar (Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.tsx:198:79) at Component (node_modules/react-dom/cjs/react-dom.development.js:15486:18) at renderWithHooks (node_modules/react-dom/cjs/react-dom.development.js:20103:13) at mountIndeterminateComponent (node_modules/react-dom/cjs/react-dom.development.js:21626:16) at beginWork (node_modules/react-dom/cjs/react-dom.development.js:27465:14) at beginWork$1 (node_modules/react-dom/cjs/react-dom.development.js:26599:12) at performUnitOfWork (node_modules/react-dom/cjs/react-dom.development.js:26505:5) at workLoopSync (node_modules/react-dom/cjs/react-dom.development.js:26473:7) at renderRootSync (node_modules/react-dom/cjs/react-dom.development.js:25889:20) at recoverFromConcurrentError (node_modules/react-dom/cjs/react-dom.development.js:25789:22) at callback (node_modules/react/cjs/react.development.js:2667:24) at flushActQueue (node_modules/react/cjs/react.development.js:2582:11) at actImplementation (node_modules/@testing-library/react/dist/act-compat.js:47:25) at renderRoot (node_modules/@testing-library/react/dist/pure.js:190:25) at renderRoot (node_modules/@testing-library/react/dist/pure.js:292:10) at Object.<anonymous> (Assets/web-components/SearchOrAskAi/AskAi/ChatMessage.test.tsx:144:19)
mutationFn: submitFeedbackToApi,
onError: (error) => {
logWarn('Error submitting feedback', {
'error.message':
error instanceof Error ? error.message : String(error),
})
// Don't reset selection on error - user intent was clear
},
})

const submitFeedback = useCallback(
(reaction: Reaction) => {
if (!conversationId) {
console.warn('Cannot submit feedback without conversationId')
return
}

// Ignore if same reaction already selected
if (selectedReaction === reaction) {
return
}

// Ignore if already submitting
if (mutation.isPending) {
return
}

// Optimistic update
setSelectedReaction(reaction)

// Submit to API
mutation.mutate({
messageId,
conversationId,
reaction,
})
},
[messageId, conversationId, selectedReaction, mutation]
)

return {
selectedReaction,
submitFeedback,
isPending: mutation.isPending,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Text.Json.Serialization;

namespace Elastic.Documentation.Api.Core.AskAi;

/// <summary>
/// Request model for submitting feedback on a specific Ask AI message.
/// </summary>
public record AskAiMessageFeedbackRequest(
string MessageId,
string ConversationId,
Reaction Reaction
);

/// <summary>
/// The user's reaction to an Ask AI message.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<Reaction>))]
public enum Reaction
{
[JsonStringEnumMemberName("thumbsUp")]
ThumbsUp,

[JsonStringEnumMemberName("thumbsDown")]
ThumbsDown
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Diagnostics;
using Microsoft.Extensions.Logging;

namespace Elastic.Documentation.Api.Core.AskAi;

/// <summary>
/// Use case for handling Ask AI message feedback submissions.
/// </summary>
public class AskAiMessageFeedbackUsecase(
IAskAiMessageFeedbackGateway feedbackGateway,
ILogger<AskAiMessageFeedbackUsecase> logger)
{
private static readonly ActivitySource FeedbackActivitySource = new(TelemetryConstants.AskAiFeedbackSourceName);

public async Task SubmitFeedback(AskAiMessageFeedbackRequest request, string? euid, CancellationToken ctx)
{
using var activity = FeedbackActivitySource.StartActivity("record message-feedback", ActivityKind.Internal);
_ = activity?.SetTag("gen_ai.conversation.id", request.ConversationId); // correlation with chat traces
_ = activity?.SetTag("ask_ai.message.id", request.MessageId);
_ = activity?.SetTag("ask_ai.feedback.reaction", request.Reaction.ToString().ToLowerInvariant());
// Note: user.euid is automatically added to spans by EuidSpanProcessor

logger.LogInformation(
"Recording message feedback for message {MessageId} in conversation {ConversationId}: {Reaction}",
LogSanitizer.Sanitize(request.MessageId),
LogSanitizer.Sanitize(request.ConversationId),
request.Reaction);

var record = new AskAiMessageFeedbackRecord(
request.MessageId,
request.ConversationId,
request.Reaction,
euid
);

await feedbackGateway.RecordFeedbackAsync(record, ctx);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public async Task<Stream> AskAi(AskAiRequest askAiRequest, Cancel ctx)
};
var inputMessagesJson = JsonSerializer.Serialize(inputMessages, ApiJsonContext.Default.InputMessageArray);
_ = activity?.SetTag("gen_ai.input.messages", inputMessagesJson);
logger.LogInformation("AskAI input message: {ask_ai.input.message}", askAiRequest.Message);
logger.LogInformation("AskAI input message: {ask_ai.input.message}", LogSanitizer.Sanitize(askAiRequest.Message));
logger.LogInformation("Streaming AskAI response");
var rawStream = await askAiGateway.AskAi(askAiRequest, ctx);
// The stream transformer will handle disposing the activity when streaming completes
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

namespace Elastic.Documentation.Api.Core.AskAi;

/// <summary>
/// Gateway interface for recording Ask AI message feedback.
/// Infrastructure implementations may use different storage backends (Elasticsearch, database, etc.)
/// </summary>
public interface IAskAiMessageFeedbackGateway
{
/// <summary>
/// Records feedback for a specific Ask AI message.
/// </summary>
/// <param name="record">The feedback record to store.</param>
/// <param name="ctx">Cancellation token.</param>
Task RecordFeedbackAsync(AskAiMessageFeedbackRecord record, CancellationToken ctx);
}

/// <summary>
/// Internal record used to pass message feedback data to the gateway.
/// </summary>
public record AskAiMessageFeedbackRecord(
string MessageId,
string ConversationId,
Reaction Reaction,
string? Euid = null
);
20 changes: 20 additions & 0 deletions src/api/Elastic.Documentation.Api.Core/LogSanitizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

namespace Elastic.Documentation.Api.Core;

/// <summary>
/// Utility for sanitizing user input before logging to prevent log forging attacks.
/// </summary>
public static class LogSanitizer
{
/// <summary>
/// Sanitizes a string for safe logging by removing newline and carriage return characters.
/// This prevents log forging attacks where malicious input could inject fake log entries.
/// </summary>
/// <param name="input">The input string to sanitize.</param>
/// <returns>The sanitized string with newlines removed, or empty string if input is null.</returns>
public static string Sanitize(string? input) =>
input?.Replace("\r", "").Replace("\n", "") ?? string.Empty;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ public record InputMessage(string Role, MessagePart[] Parts);
public record OutputMessage(string Role, MessagePart[] Parts, string FinishReason);

[JsonSerializable(typeof(AskAiRequest))]
[JsonSerializable(typeof(AskAiMessageFeedbackRequest))]
[JsonSerializable(typeof(Reaction))]
[JsonSerializable(typeof(SearchRequest))]
[JsonSerializable(typeof(SearchResponse))]
[JsonSerializable(typeof(InputMessage))]
Expand Down
Loading
Loading