Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/insight-generated-answer-stream-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coveo/headless": patch
---

Fixed `logGeneratedAnswerStreamEnd` always logging the Search analytics event, even when the `GeneratedAnswer` controller is built on an Insight engine. The Insight-specific `logGeneratedAnswerStreamEnd` action is now correctly invoked for Insight use cases, and its signature was aligned with the Search version (`answerGenerated`, `answerId?`, `answerTextIsEmpty?`).
30 changes: 25 additions & 5 deletions packages/headless/src/api/knowledge/stream-answer-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import {
} from '../../features/generated-answer/generated-answer-actions.js';
import {
logGeneratedAnswerResponseLinked,
logGeneratedAnswerStreamEnd,
generatedAnswerAnalyticsClient as searchGeneratedAnswerAnalyticsClient,
} from '../../features/generated-answer/generated-answer-analytics-actions.js';
import type {GeneratedAnswerAnalyticsClient} from '../../features/generated-answer/generated-answer-analytics-client.js';
import type {AnswerApiQueryParams} from '../../features/generated-answer/generated-answer-request.js';
import {fetchEventSource} from '../../utils/fetch-event-source/fetch.js';
import type {EventSourceMessage} from '../../utils/fetch-event-source/parse.js';
Expand Down Expand Up @@ -99,7 +100,8 @@ const handleError = (draft: GeneratedAnswerStream, message: Required<MessageType
export const updateCacheWithEvent = (
event: EventSourceMessage,
draft: GeneratedAnswerStream,
dispatch: ThunkDispatch<StreamAnswerAPIState, unknown, UnknownAction>
dispatch: ThunkDispatch<StreamAnswerAPIState, unknown, UnknownAction>,
generatedAnswerAnalyticsClient: GeneratedAnswerAnalyticsClient
) => {
const message: Required<MessageType> = JSON.parse(event.data);
if (message.finishReason === 'ERROR' && message.errorMessage) {
Expand Down Expand Up @@ -132,7 +134,13 @@ export const updateCacheWithEvent = (
const answerId = draft.answerId;
const answerGenerated = parsedPayload.answerGenerated ?? false;
const answerTextIsEmpty = answerGenerated ? !draft.answer?.trim() : undefined;
dispatch(logGeneratedAnswerStreamEnd(answerGenerated, answerId, answerTextIsEmpty));
dispatch(
generatedAnswerAnalyticsClient.logGeneratedAnswerStreamEnd(
answerGenerated,
answerId,
answerTextIsEmpty
)
);
dispatch(logGeneratedAnswerResponseLinked());
break;
}
Expand Down Expand Up @@ -177,7 +185,10 @@ export const answerApi = answerSlice.injectEndpoints({
// Standard RTK key, with analytics excluded
return `${endpointName}(${JSON.stringify(queryArgsWithoutAnalytics)})`;
},
async onCacheEntryAdded(args, {getState, cacheDataLoaded, updateCachedData, dispatch}) {
async onCacheEntryAdded(
args,
{getState, cacheDataLoaded, updateCachedData, dispatch, extra}
) {
await cacheDataLoaded;
/**
* createApi has to be called prior to creating the redux store and is used as part of the store setup sequence.
Expand All @@ -186,6 +197,10 @@ export const answerApi = answerSlice.injectEndpoints({
*/
const {configuration, generatedAnswer, insightConfiguration} =
getState() as unknown as StreamAnswerAPIState;
// We have to await here because the extra arguments we receive are a promise because of the 'renew-access-token-middleware.ts'.
const {generatedAnswerAnalyticsClient} = (await extra) as {
generatedAnswerAnalyticsClient?: GeneratedAnswerAnalyticsClient;
};
const {organizationId, environment, accessToken} = configuration;
Comment thread
erocheleau marked this conversation as resolved.
const platformEndpoint = getApiBaseUrlOrOrganizationEndpoint(
configuration.search.apiBaseUrl,
Expand Down Expand Up @@ -220,7 +235,12 @@ export const answerApi = answerSlice.injectEndpoints({
},
onmessage: (event) => {
updateCachedData((draft) => {
updateCacheWithEvent(event, draft, dispatch);
updateCacheWithEvent(
event,
draft,
dispatch,
generatedAnswerAnalyticsClient ?? searchGeneratedAnswerAnalyticsClient
);
});
},
onerror: (error) => {
Expand Down
57 changes: 41 additions & 16 deletions packages/headless/src/api/knowledge/tests/stream-answer-api.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
/* oxlint-disable @typescript-eslint/no-explicit-any -- Just tests */
import type {EventSourceMessage} from '../../../utils/fetch-event-source/parse.js';
import type {GeneratedAnswerAnalyticsClient} from '../../../features/generated-answer/generated-answer-analytics-client.js';
import type {GeneratedAnswerStream} from '../generated-answer-stream.js';
import {buildAnswerEndpoint, updateCacheWithEvent} from '../stream-answer-api.js';

describe('#streamAnswerApi', () => {
describe('updateCacheWithEvent', () => {
const mockGeneratedAnswerAnalyticsClient = {
logGeneratedAnswerStreamEnd: vi.fn(),
} as unknown as GeneratedAnswerAnalyticsClient;
Comment thread
erocheleau marked this conversation as resolved.

const buildEvent = (data: Record<string, any>): EventSourceMessage => {
return {
id: '001',
Expand Down Expand Up @@ -57,7 +62,7 @@ describe('#streamAnswerApi', () => {

const draft = buildDefaultDraft();

updateCacheWithEvent(event, draft, dispatch);
updateCacheWithEvent(event, draft, dispatch, mockGeneratedAnswerAnalyticsClient);

expect(draft).toHaveProperty('error', {
message: 'some error',
Expand All @@ -75,7 +80,7 @@ describe('#streamAnswerApi', () => {
});
const draft = buildDefaultDraft();

updateCacheWithEvent(event, draft, dispatch);
updateCacheWithEvent(event, draft, dispatch, mockGeneratedAnswerAnalyticsClient);

expect(draft).toHaveProperty('contentFormat', 'text/markdown');
expect(draft).toHaveProperty('isStreaming', true);
Expand All @@ -97,7 +102,7 @@ describe('#streamAnswerApi', () => {
payloadType: 'genqa.messageType',
});
const draft = buildDefaultDraft();
updateCacheWithEvent(errorEvent, draft, dispatch);
updateCacheWithEvent(errorEvent, draft, dispatch, mockGeneratedAnswerAnalyticsClient);
expect(draft.error).toEqual({message: 'transient error', code: 500});

const messageEvent = buildSuccessEvent({
Expand All @@ -106,7 +111,7 @@ describe('#streamAnswerApi', () => {
textDelta: 'a full valid answer',
},
});
updateCacheWithEvent(messageEvent, draft, dispatch);
updateCacheWithEvent(messageEvent, draft, dispatch, mockGeneratedAnswerAnalyticsClient);

expect(draft.answer).toBe('a full valid answer');
expect(draft.error).toBeUndefined();
Expand All @@ -122,7 +127,7 @@ describe('#streamAnswerApi', () => {
payloadType: 'genqa.citationsType',
});
const draft = buildDefaultDraft();
updateCacheWithEvent(errorEvent, draft, dispatch);
updateCacheWithEvent(errorEvent, draft, dispatch, mockGeneratedAnswerAnalyticsClient);
expect(draft.error).toEqual({message: 'transient error', code: 500});

const citation = {
Expand All @@ -138,7 +143,7 @@ describe('#streamAnswerApi', () => {
citations: [citation],
},
});
updateCacheWithEvent(citationsEvent, draft, dispatch);
updateCacheWithEvent(citationsEvent, draft, dispatch, mockGeneratedAnswerAnalyticsClient);

expect(draft.citations).toEqual([citation]);
expect(draft.error).toBeUndefined();
Expand All @@ -155,7 +160,7 @@ describe('#streamAnswerApi', () => {
});
const draft = buildDefaultDraft({answer: undefined});

updateCacheWithEvent(event, draft, dispatch);
updateCacheWithEvent(event, draft, dispatch, mockGeneratedAnswerAnalyticsClient);

expect(draft).toHaveProperty('answer', 'some answer');
expect(dispatch).toHaveBeenCalledWith({
Expand All @@ -176,7 +181,7 @@ describe('#streamAnswerApi', () => {
});
const draft = buildDefaultDraft({answer: undefined});

updateCacheWithEvent(event, draft, dispatch);
updateCacheWithEvent(event, draft, dispatch, mockGeneratedAnswerAnalyticsClient);

expect(draft.answer).toBeUndefined();
expect(dispatch).toHaveBeenCalledWith({
Expand All @@ -197,7 +202,7 @@ describe('#streamAnswerApi', () => {
});
const draft = buildDefaultDraft({answer: 'some answer '});

updateCacheWithEvent(event, draft, dispatch);
updateCacheWithEvent(event, draft, dispatch, mockGeneratedAnswerAnalyticsClient);

expect(draft).toHaveProperty('answer', 'some answer with some more info');
expect(dispatch).toHaveBeenCalledWith({
Expand All @@ -218,7 +223,9 @@ describe('#streamAnswerApi', () => {
});
const draft = buildDefaultDraft({answer: 'existing answer'});

expect(() => updateCacheWithEvent(event, draft, dispatch)).not.toThrow();
expect(() =>
updateCacheWithEvent(event, draft, dispatch, mockGeneratedAnswerAnalyticsClient)
).not.toThrow();
expect(draft).toHaveProperty('answer', 'existing answer');
expect(dispatch).not.toHaveBeenCalled();
});
Expand All @@ -240,7 +247,7 @@ describe('#streamAnswerApi', () => {
});
const draft = buildDefaultDraft();

updateCacheWithEvent(event, draft, dispatch);
updateCacheWithEvent(event, draft, dispatch, mockGeneratedAnswerAnalyticsClient);

expect(draft).toHaveProperty('citations', [citation]);
expect(dispatch).toHaveBeenCalledWith({
Expand All @@ -253,23 +260,38 @@ describe('#streamAnswerApi', () => {

it('should handle end of stream message when answer is generated', () => {
const dispatch = vi.fn();
const logGeneratedAnswerStreamEnd = vi.fn().mockReturnValue({
type: 'analytics/generatedAnswer/streamEnd',
});
const generatedAnswerAnalyticsClient = {
logGeneratedAnswerStreamEnd,
} as unknown as GeneratedAnswerAnalyticsClient;
Comment thread
erocheleau marked this conversation as resolved.
const event = buildSuccessEvent({
payloadType: 'genqa.endOfStreamType',
payload: {
answerGenerated: true,
},
});
const draft = buildDefaultDraft({answer: 'some answer'});
const draft = buildDefaultDraft({answer: 'some answer', answerId: '1'});

updateCacheWithEvent(event, draft, dispatch);
updateCacheWithEvent(event, draft, dispatch, generatedAnswerAnalyticsClient);

expect(draft).toHaveProperty('generated', true);
expect(draft).toHaveProperty('isStreaming', false);
expect(dispatch).toHaveBeenCalled();
expect(logGeneratedAnswerStreamEnd).toHaveBeenCalledWith(true, '1', false);
expect(dispatch).toHaveBeenCalledWith({
type: 'analytics/generatedAnswer/streamEnd',
});
});

it('should handle end of stream message when answer is not generated', () => {
const dispatch = vi.fn();
const logGeneratedAnswerStreamEnd = vi.fn().mockReturnValue({
type: 'analytics/generatedAnswer/streamEnd',
});
const generatedAnswerAnalyticsClient = {
logGeneratedAnswerStreamEnd,
} as unknown as GeneratedAnswerAnalyticsClient;
Comment thread
erocheleau marked this conversation as resolved.
const event = buildSuccessEvent({
payloadType: 'genqa.endOfStreamType',
payload: {
Expand All @@ -278,11 +300,14 @@ describe('#streamAnswerApi', () => {
});
const draft = buildDefaultDraft();

updateCacheWithEvent(event, draft, dispatch);
updateCacheWithEvent(event, draft, dispatch, generatedAnswerAnalyticsClient);

expect(draft).toHaveProperty('generated', false);
expect(draft).toHaveProperty('isStreaming', false);
expect(dispatch).toHaveBeenCalled();
expect(logGeneratedAnswerStreamEnd).toHaveBeenCalledWith(false, undefined, undefined);
expect(dispatch).toHaveBeenCalledWith({
type: 'analytics/generatedAnswer/streamEnd',
});
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as InsightInterfaceActions from '../../features/insight-interface/insight-interface-actions.js';
import * as ConfigurationActions from '../../features/configuration/configuration-actions.js';
import {generatedAnswerInsightAnalyticsClient} from '../../features/generated-answer/generated-answer-insight-analytics-actions.js';
import {nextAnalyticsUsageWithServiceFeatureWarning} from '../engine.js';
import {getSampleEngineConfiguration} from '../engine-configuration.js';
import {
Expand Down Expand Up @@ -179,4 +180,12 @@ describe('buildInsightEngine', () => {

expect(thunkRelay).toBe(engine.relay);
});

it('should register the Insight generatedAnswerAnalyticsClient as a thunk extra argument', async () => {
const thunkAnalyticsClient = await engine.dispatch(
(_dispatch, _getState, extra) => extra.generatedAnswerAnalyticsClient
);

expect(thunkAnalyticsClient).toBe(generatedAnswerInsightAnalyticsClient);
});
});
2 changes: 2 additions & 0 deletions packages/headless/src/app/insight-engine/insight-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from '../../features/configuration/configuration-actions.js';
import {setInsightConfiguration} from '../../features/insight-configuration/insight-configuration-actions.js';
import {insightConfigurationReducer as insightConfiguration} from '../../features/insight-configuration/insight-configuration-slice.js';
import {generatedAnswerInsightAnalyticsClient} from '../../features/generated-answer/generated-answer-insight-analytics-actions.js';
import {fetchInterface} from '../../features/insight-interface/insight-interface-actions.js';
import {insightInterfaceReducer as insightInterface} from '../../features/insight-interface/insight-interface-slice.js';
import {executeSearch} from '../../features/insight-search/insight-search-actions.js';
Expand Down Expand Up @@ -118,6 +119,7 @@ export function buildInsightEngine(options: InsightEngineOptions): InsightEngine
...buildThunkExtraArguments(options.configuration, logger),
apiClient: insightAPIClient,
streamingClient: generatedAnswerClient,
generatedAnswerAnalyticsClient: generatedAnswerInsightAnalyticsClient,
};

const augmentedOptions: EngineOptions<InsightEngineReducers> = {
Expand Down
5 changes: 4 additions & 1 deletion packages/headless/src/app/insight-thunk-extra-arguments.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type {InsightAPIClient} from '../api/service/insight/insight-api-client.js';
import type {GeneratedAnswerAnalyticsClient} from '../features/generated-answer/generated-answer-analytics-client.js';
import type {ClientThunkExtraArguments} from './thunk-extra-arguments.js';

export interface InsightThunkExtraArguments extends ClientThunkExtraArguments<InsightAPIClient> {}
export interface InsightThunkExtraArguments extends ClientThunkExtraArguments<InsightAPIClient> {
generatedAnswerAnalyticsClient: GeneratedAnswerAnalyticsClient;
}
9 changes: 9 additions & 0 deletions packages/headless/src/app/search-engine/search-engine.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {enableDebug} from '../../features/debug/debug-actions.js';
import * as ConfigurationActions from '../../features/configuration/configuration-actions.js';
import {generatedAnswerAnalyticsClient} from '../../features/generated-answer/generated-answer-analytics-actions.js';
import {setSearchHub} from '../../features/search-hub/search-hub-actions.js';
import {buildSearchEngine, type SearchEngine, type SearchEngineOptions} from './search-engine.js';
import {getSampleSearchEngineConfiguration} from './search-engine-configuration.js';
Expand Down Expand Up @@ -216,5 +217,13 @@ describe('searchEngine', () => {

expect(thunkRelay).toBe(engine.relay);
});

it('should register the Search generatedAnswerAnalyticsClient as a thunk extra argument', async () => {
const thunkAnalyticsClient = await engine.dispatch(
(_dispatch, _getState, extra) => extra.generatedAnswerAnalyticsClient
);

expect(thunkAnalyticsClient).toBe(generatedAnswerAnalyticsClient);
});
});
});
2 changes: 2 additions & 0 deletions packages/headless/src/app/search-engine/search-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from '../../features/configuration/configuration-actions.js';
import type {ConfigurationState} from '../../features/configuration/configuration-state.js';
import {debugReducer as debug} from '../../features/debug/debug-slice.js';
import {generatedAnswerAnalyticsClient} from '../../features/generated-answer/generated-answer-analytics-actions.js';
import {pipelineReducer as pipeline} from '../../features/pipeline/pipeline-slice.js';
import {executeSearch} from '../../features/search/search-actions.js';
import {firstSearchExecutedSelector} from '../../features/search/search-selectors.js';
Expand Down Expand Up @@ -128,6 +129,7 @@ export function buildSearchEngine(options: SearchEngineOptions): SearchEngine {
...buildThunkExtraArguments(configuration, logger),
apiClient: searchAPIClient,
streamingClient: generatedAnswerClient,
generatedAnswerAnalyticsClient,
};

const augmentedOptions: EngineOptions<SearchEngineReducers> = {
Expand Down
5 changes: 4 additions & 1 deletion packages/headless/src/app/search-thunk-extra-arguments.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type {GeneratedAnswerAPIClient} from '../api/generated-answer/generated-answer-client.js';
import type {SearchAPIClient} from '../api/search/search-api-client.js';
import type {GeneratedAnswerAnalyticsClient} from '../features/generated-answer/generated-answer-analytics-client.js';
import type {ClientThunkExtraArguments} from './thunk-extra-arguments.js';

export interface SearchThunkExtraArguments extends ClientThunkExtraArguments<
SearchAPIClient,
GeneratedAnswerAPIClient
> {}
> {
generatedAnswerAnalyticsClient: GeneratedAnswerAnalyticsClient;
}
2 changes: 2 additions & 0 deletions packages/headless/src/app/thunk-extra-arguments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {AnalyticsClientSendEventHook} from 'coveo.analytics';
import type {Logger} from 'pino';
import type {GeneratedAnswerAPIClient} from '../api/generated-answer/generated-answer-client.js';
import {NoopPreprocessRequest, type PreprocessRequest} from '../api/preprocess-request.js';
import type {GeneratedAnswerAnalyticsClient} from '../features/generated-answer/generated-answer-analytics-client.js';
import {validatePayloadAndThrow} from '../utils/validate-payload.js';
import type {EngineConfiguration} from './engine-configuration.js';
import type {NavigatorContext} from './navigator-context-provider.js';
Expand All @@ -15,6 +16,7 @@ export interface ClientThunkExtraArguments<
streamingClient?: K;
relay: Relay;
navigatorContext: NavigatorContext;
generatedAnswerAnalyticsClient?: GeneratedAnswerAnalyticsClient;
}

export interface ThunkExtraArguments {
Expand Down
Loading
Loading