diff --git a/packages/headless/src/api/knowledge/answer-generation/agents/answer-agent/answer-agent-runner.test.ts b/packages/headless/src/api/knowledge/answer-generation/agents/answer-agent/answer-agent-runner.test.ts index e55de7af52a..621c83475d1 100644 --- a/packages/headless/src/api/knowledge/answer-generation/agents/answer-agent/answer-agent-runner.test.ts +++ b/packages/headless/src/api/knowledge/answer-generation/agents/answer-agent/answer-agent-runner.test.ts @@ -62,6 +62,10 @@ describe('createAnswerRunner', () => { }; const navigatorProvider = vi.fn(() => navigatorContext); const exampleStrategy = {onRunStartedEvent: () => {}}; + const analytics = { + logGeneratedAnswerStreamEnd: vi.fn(), + logGeneratedAnswerResponseLinked: vi.fn(), + } as any; beforeEach(() => { vi.clearAllMocks(); @@ -86,8 +90,8 @@ describe('createAnswerRunner', () => { const runner = buildRunner(); await Promise.all([ - runner.run(state, dispatch, navigatorProvider), - runner.run(state, dispatch, navigatorProvider), + runner.run(state, dispatch, navigatorProvider, analytics), + runner.run(state, dispatch, navigatorProvider, analytics), ]); expect(abortRunMock).toHaveBeenCalledTimes(1); @@ -96,7 +100,7 @@ describe('createAnswerRunner', () => { it('creates the agent using configuration selectors', async () => { const runner = buildRunner(); - await runner.run(state, dispatch, navigatorProvider); + await runner.run(state, dispatch, navigatorProvider, analytics); expect(createAnswerAgent).toHaveBeenCalledWith( 'agent-123', @@ -108,9 +112,9 @@ describe('createAnswerRunner', () => { it('builds the strategy and executes the agent with forwarded props', async () => { const runner = buildRunner(); - await runner.run(state, dispatch, navigatorProvider); + await runner.run(state, dispatch, navigatorProvider, analytics); - expect(createHeadAnswerStrategy).toHaveBeenCalledWith(dispatch); + expect(createHeadAnswerStrategy).toHaveBeenCalledWith(dispatch, analytics); expect(constructGenerateHeadAnswerParams).toHaveBeenCalledWith( state, navigatorContext @@ -132,7 +136,7 @@ describe('createAnswerRunner', () => { vi.mocked(selectDebugAgentSession).mockReturnValue(true); const runner = buildRunner(); - await runner.run(state, dispatch, navigatorProvider); + await runner.run(state, dispatch, navigatorProvider, analytics); expect(mockAgent.runAgent).toHaveBeenCalledWith( { @@ -149,7 +153,7 @@ describe('createAnswerRunner', () => { it('exposes an abortRun helper that cancels the active agent', async () => { const runner = buildRunner(); - await runner.run(state, dispatch, navigatorProvider); + await runner.run(state, dispatch, navigatorProvider, analytics); runner.abortRun(); expect(abortRunMock).toHaveBeenCalledTimes(1); @@ -158,7 +162,7 @@ describe('createAnswerRunner', () => { it('sets isRunning to false on the agent when abortRun is called', async () => { const runner = buildRunner(); - await runner.run(state, dispatch, navigatorProvider); + await runner.run(state, dispatch, navigatorProvider, analytics); mockAgent.isRunning = true; runner.abortRun(); @@ -168,7 +172,7 @@ describe('createAnswerRunner', () => { it('dispatches the loading state when a run starts', async () => { const runner = buildRunner(); - await runner.run(state, dispatch, navigatorProvider); + await runner.run(state, dispatch, navigatorProvider, analytics); expect(dispatch).toHaveBeenCalledWith(setIsLoading(true)); }); @@ -179,7 +183,7 @@ describe('createAnswerRunner', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); runAgentMock.mockRejectedValueOnce(error); - await runner.run(state, dispatch, navigatorProvider); + await runner.run(state, dispatch, navigatorProvider, analytics); expect(dispatch).toHaveBeenCalledWith(setIsLoading(true)); expect(dispatch).toHaveBeenCalledWith( diff --git a/packages/headless/src/api/knowledge/answer-generation/agents/answer-agent/answer-agent-runner.ts b/packages/headless/src/api/knowledge/answer-generation/agents/answer-agent/answer-agent-runner.ts index 0ab80e8a5ef..836718961d3 100644 --- a/packages/headless/src/api/knowledge/answer-generation/agents/answer-agent/answer-agent-runner.ts +++ b/packages/headless/src/api/knowledge/answer-generation/agents/answer-agent/answer-agent-runner.ts @@ -1,5 +1,6 @@ import type {Dispatch} from '@reduxjs/toolkit'; import type {NavigatorContext} from '../../../../../app/navigator-context-provider.js'; +import type {AnswerGenerationAnalyticsClient} from '../../../../../features/generated-answer/answer-generation-analytics-client.js'; import { selectAccessToken, selectAgentId, @@ -39,7 +40,8 @@ export const createAnswerRunner = () => { const run = async ( state: StateNeededForHeadAnswerParams, dispatch: Dispatch, - getNavigatorContext: () => NavigatorContext + getNavigatorContext: () => NavigatorContext, + analytics: AnswerGenerationAnalyticsClient ) => { abortRun(); @@ -53,7 +55,7 @@ export const createAnswerRunner = () => { currentAgent = agent; - const strategy = createHeadAnswerStrategy(dispatch); + const strategy = createHeadAnswerStrategy(dispatch, analytics); const params = constructGenerateHeadAnswerParams( state, getNavigatorContext() diff --git a/packages/headless/src/api/knowledge/answer-generation/agents/answer-agent/head-answer-strategy.test.ts b/packages/headless/src/api/knowledge/answer-generation/agents/answer-agent/head-answer-strategy.test.ts index b581b39f751..4f650879759 100644 --- a/packages/headless/src/api/knowledge/answer-generation/agents/answer-agent/head-answer-strategy.test.ts +++ b/packages/headless/src/api/knowledge/answer-generation/agents/answer-agent/head-answer-strategy.test.ts @@ -24,13 +24,16 @@ import { updateError, updateMessage, } from '../../../../../features/generated-answer/generated-answer-actions.js'; -import * as generatedAnswerAnalyticsActions from '../../../../../features/generated-answer/generated-answer-analytics-actions.js'; import {GeneratedAnswerSseErrorCode} from '../../../../../features/generated-answer/sse-generated-answer-errors.js'; import {createHeadAnswerStrategy} from './head-answer-strategy.js'; describe('createHeadAnswerStrategy', () => { let dispatch: ReturnType & Dispatch; let strategy: AgentSubscriber; + let analytics: { + logGeneratedAnswerStreamEnd: ReturnType; + logGeneratedAnswerResponseLinked: ReturnType; + }; afterEach(() => { vi.restoreAllMocks(); @@ -38,7 +41,11 @@ describe('createHeadAnswerStrategy', () => { beforeEach(() => { dispatch = vi.fn() as unknown as ReturnType & Dispatch; - strategy = createHeadAnswerStrategy(dispatch); + analytics = { + logGeneratedAnswerStreamEnd: vi.fn(), + logGeneratedAnswerResponseLinked: vi.fn(), + }; + strategy = createHeadAnswerStrategy(dispatch, analytics as any); }); it('initializes identifiers and streaming state when a run starts', () => { @@ -154,14 +161,11 @@ describe('createHeadAnswerStrategy', () => { it('finalizes the run with the answer generation result', () => { const streamEndAction = vi.fn() as any; const responseLinkedAction = vi.fn() as any; - const streamEndSpy = vi - .spyOn(generatedAnswerAnalyticsActions, 'logGeneratedAnswerStreamEnd') - .mockReturnValue(streamEndAction); - vi.spyOn( - generatedAnswerAnalyticsActions, - 'logGeneratedAnswerResponseLinked' - ).mockReturnValue(responseLinkedAction); - strategy = createHeadAnswerStrategy(dispatch); + analytics.logGeneratedAnswerStreamEnd.mockReturnValue(streamEndAction); + analytics.logGeneratedAnswerResponseLinked.mockReturnValue( + responseLinkedAction + ); + strategy = createHeadAnswerStrategy(dispatch, analytics as any); strategy.onRunStartedEvent!({ event: {runId: 'run-001', threadId: 'thread-007'}, } as any); @@ -180,7 +184,11 @@ describe('createHeadAnswerStrategy', () => { expect(dispatch).toHaveBeenNthCalledWith(1, setIsAnswerGenerated(true)); expect(dispatch).toHaveBeenNthCalledWith(2, setCannotAnswer(false)); expect(dispatch).toHaveBeenNthCalledWith(3, setIsStreaming(false)); - expect(streamEndSpy).toHaveBeenCalledWith(true, 'run-001', true); + expect(analytics.logGeneratedAnswerStreamEnd).toHaveBeenCalledWith( + true, + 'run-001', + true + ); expect(dispatch).toHaveBeenNthCalledWith(4, streamEndAction); expect(dispatch).toHaveBeenNthCalledWith(5, responseLinkedAction); }); @@ -188,14 +196,11 @@ describe('createHeadAnswerStrategy', () => { it('disables answer flags when no answer was generated', () => { const streamEndAction = vi.fn() as any; const responseLinkedAction = vi.fn() as any; - const streamEndSpy = vi - .spyOn(generatedAnswerAnalyticsActions, 'logGeneratedAnswerStreamEnd') - .mockReturnValue(streamEndAction); - vi.spyOn( - generatedAnswerAnalyticsActions, - 'logGeneratedAnswerResponseLinked' - ).mockReturnValue(responseLinkedAction); - strategy = createHeadAnswerStrategy(dispatch); + analytics.logGeneratedAnswerStreamEnd.mockReturnValue(streamEndAction); + analytics.logGeneratedAnswerResponseLinked.mockReturnValue( + responseLinkedAction + ); + strategy = createHeadAnswerStrategy(dispatch, analytics as any); strategy.onRunStartedEvent!({ event: {runId: 'run-001', threadId: 'thread-007'}, } as any); @@ -214,7 +219,11 @@ describe('createHeadAnswerStrategy', () => { expect(dispatch).toHaveBeenNthCalledWith(1, setIsAnswerGenerated(false)); expect(dispatch).toHaveBeenNthCalledWith(2, setCannotAnswer(true)); expect(dispatch).toHaveBeenNthCalledWith(3, setIsStreaming(false)); - expect(streamEndSpy).toHaveBeenCalledWith(false, 'run-001', undefined); + expect(analytics.logGeneratedAnswerStreamEnd).toHaveBeenCalledWith( + false, + 'run-001', + undefined + ); expect(dispatch).toHaveBeenNthCalledWith(4, streamEndAction); expect(dispatch).toHaveBeenNthCalledWith(5, responseLinkedAction); }); diff --git a/packages/headless/src/api/knowledge/answer-generation/agents/answer-agent/head-answer-strategy.ts b/packages/headless/src/api/knowledge/answer-generation/agents/answer-agent/head-answer-strategy.ts index 108464eeacf..75bd5212843 100644 --- a/packages/headless/src/api/knowledge/answer-generation/agents/answer-agent/head-answer-strategy.ts +++ b/packages/headless/src/api/knowledge/answer-generation/agents/answer-agent/head-answer-strategy.ts @@ -22,10 +22,7 @@ import { updateError, updateMessage, } from '../../../../../features/generated-answer/generated-answer-actions.js'; -import { - logGeneratedAnswerResponseLinked, - logGeneratedAnswerStreamEnd, -} from '../../../../../features/generated-answer/generated-answer-analytics-actions.js'; +import type {AnswerGenerationAnalyticsClient} from '../../../../../features/generated-answer/answer-generation-analytics-client.js'; import type { GenerationStepName, GenerationToolCallArgsGeneric, @@ -37,7 +34,8 @@ import {mapRunErrorCode} from '../../../../../features/generated-answer/sse-gene * Creates an AgentSubscriber that handles answer streaming events */ export const createHeadAnswerStrategy = ( - dispatch: ThunkDispatch + dispatch: ThunkDispatch, + analytics: AnswerGenerationAnalyticsClient ): AgentSubscriber => { let runId = ''; let answerHasText = false; @@ -191,9 +189,13 @@ export const createHeadAnswerStrategy = ( dispatch(setCannotAnswer(!answerGenerated)); dispatch(setIsStreaming(false)); dispatch( - logGeneratedAnswerStreamEnd(answerGenerated, runId, answerTextIsEmpty) + analytics.logGeneratedAnswerStreamEnd( + answerGenerated, + runId, + answerTextIsEmpty + ) ); - dispatch(logGeneratedAnswerResponseLinked()); + dispatch(analytics.logGeneratedAnswerResponseLinked()); }, }; }; diff --git a/packages/headless/src/api/knowledge/answer-generation/agents/follow-up-agent/follow-up-answer-strategy.test.ts b/packages/headless/src/api/knowledge/answer-generation/agents/follow-up-agent/follow-up-answer-strategy.test.ts index 853efe78e3c..8af9ac6c352 100644 --- a/packages/headless/src/api/knowledge/answer-generation/agents/follow-up-agent/follow-up-answer-strategy.test.ts +++ b/packages/headless/src/api/knowledge/answer-generation/agents/follow-up-agent/follow-up-answer-strategy.test.ts @@ -18,13 +18,16 @@ import { setFollowUpIsLoading, setFollowUpIsStreaming, } from '../../../../../features/follow-up-answers/follow-up-answers-actions.js'; -import * as generatedAnswerAnalyticsActions from '../../../../../features/generated-answer/generated-answer-analytics-actions.js'; import {GeneratedAnswerSseErrorCode} from '../../../../../features/generated-answer/sse-generated-answer-errors.js'; import {createFollowUpStrategy} from './follow-up-answer-strategy.js'; describe('createFollowUpStrategy', () => { let dispatch: ReturnType & Dispatch; let strategy: AgentSubscriber; + let analytics: { + logGeneratedAnswerStreamEnd: ReturnType; + logGeneratedAnswerResponseLinked: ReturnType; + }; afterEach(() => { vi.restoreAllMocks(); @@ -34,7 +37,11 @@ describe('createFollowUpStrategy', () => { beforeEach(() => { dispatch = vi.fn() as unknown as ReturnType & Dispatch; - strategy = createFollowUpStrategy(dispatch); + analytics = { + logGeneratedAnswerStreamEnd: vi.fn(), + logGeneratedAnswerResponseLinked: vi.fn(), + }; + strategy = createFollowUpStrategy(dispatch, analytics as any); strategy.onRunStartedEvent!({event: {runId}} as any); vi.clearAllMocks(); }); @@ -154,7 +161,7 @@ describe('createFollowUpStrategy', () => { }); it('records turn limit failures using the input run id when no run was started', () => { - strategy = createFollowUpStrategy(dispatch); + strategy = createFollowUpStrategy(dispatch, analytics as any); strategy.onRunErrorEvent!({ input: {runId: 'run-456'}, @@ -225,16 +232,11 @@ describe('createFollowUpStrategy', () => { it('dispatches stream end and response linked analytics when a run finishes', () => { const streamEndAction = vi.fn() as any; const responseLinkedAction = vi.fn() as any; - const streamEndSpy = vi - .spyOn(generatedAnswerAnalyticsActions, 'logGeneratedAnswerStreamEnd') - .mockReturnValue(streamEndAction); - const responseLinkedSpy = vi - .spyOn( - generatedAnswerAnalyticsActions, - 'logGeneratedAnswerResponseLinked' - ) - .mockReturnValue(responseLinkedAction); - strategy = createFollowUpStrategy(dispatch); + analytics.logGeneratedAnswerStreamEnd.mockReturnValue(streamEndAction); + analytics.logGeneratedAnswerResponseLinked.mockReturnValue( + responseLinkedAction + ); + strategy = createFollowUpStrategy(dispatch, analytics as any); strategy.onRunStartedEvent!({event: {runId}} as any); vi.clearAllMocks(); @@ -249,8 +251,14 @@ describe('createFollowUpStrategy', () => { 1, followUpCompleted({answerId: runId, cannotAnswer: false}) ); - expect(streamEndSpy).toHaveBeenCalledWith(true, runId, true); - expect(responseLinkedSpy).toHaveBeenCalledWith(runId); + expect(analytics.logGeneratedAnswerStreamEnd).toHaveBeenCalledWith( + true, + runId, + true + ); + expect(analytics.logGeneratedAnswerResponseLinked).toHaveBeenCalledWith( + runId + ); expect(dispatch).toHaveBeenNthCalledWith(2, streamEndAction); expect(dispatch).toHaveBeenNthCalledWith(3, responseLinkedAction); }); diff --git a/packages/headless/src/api/knowledge/answer-generation/agents/follow-up-agent/follow-up-answer-strategy.ts b/packages/headless/src/api/knowledge/answer-generation/agents/follow-up-agent/follow-up-answer-strategy.ts index f9b0191b747..9322af56e45 100644 --- a/packages/headless/src/api/knowledge/answer-generation/agents/follow-up-agent/follow-up-answer-strategy.ts +++ b/packages/headless/src/api/knowledge/answer-generation/agents/follow-up-agent/follow-up-answer-strategy.ts @@ -15,10 +15,7 @@ import { setFollowUpIsLoading, setFollowUpIsStreaming, } from '../../../../../features/follow-up-answers/follow-up-answers-actions.js'; -import { - logGeneratedAnswerResponseLinked, - logGeneratedAnswerStreamEnd, -} from '../../../../../features/generated-answer/generated-answer-analytics-actions.js'; +import type {AnswerGenerationAnalyticsClient} from '../../../../../features/generated-answer/answer-generation-analytics-client.js'; import type { GenerationStepName, GenerationToolCallArgsGeneric, @@ -30,7 +27,8 @@ import {mapRunErrorCode} from '../../../../../features/generated-answer/sse-gene * Creates an AgentSubscriber that handles follow-up answer streaming events */ export const createFollowUpStrategy = ( - dispatch: ThunkDispatch + dispatch: ThunkDispatch, + analytics: AnswerGenerationAnalyticsClient ): AgentSubscriber => { let runId = ''; let answerHasText = false; @@ -184,9 +182,13 @@ export const createFollowUpStrategy = ( }) ); dispatch( - logGeneratedAnswerStreamEnd(answerGenerated, runId, answerTextIsEmpty) + analytics.logGeneratedAnswerStreamEnd( + answerGenerated, + runId, + answerTextIsEmpty + ) ); - dispatch(logGeneratedAnswerResponseLinked(runId)); + dispatch(analytics.logGeneratedAnswerResponseLinked(runId)); runId = ''; answerHasText = false; }, diff --git a/packages/headless/src/app/engine.ts b/packages/headless/src/app/engine.ts index 9a22fc32cef..301914faefd 100644 --- a/packages/headless/src/app/engine.ts +++ b/packages/headless/src/app/engine.ts @@ -25,6 +25,8 @@ import type { CoreConfigurationState, } from '../features/configuration/configuration-state.js'; import {versionReducer as version} from '../features/debug/version-slice.js'; +import type {AnswerGenerationAnalyticsClient} from '../features/generated-answer/answer-generation-analytics-client.js'; +import {generatedAnswerAnalyticsClient} from '../features/generated-answer/generated-answer-analytics-actions.js'; import type {SearchParametersState} from '../state/search-app-state.js'; import {doNotTrack} from '../utils/utils.js'; import {analyticsMiddleware} from './analytics-middleware.js'; @@ -351,7 +353,8 @@ function createStore< const middlewares = createMiddleware( options, thunkExtraArguments.logger, - () => thunkExtraArguments.navigatorContext + () => thunkExtraArguments.navigatorContext, + thunkExtraArguments.answerGenerationAnalyticsClient ); return configureStore({ @@ -366,7 +369,8 @@ function createStore< function createMiddleware( options: EngineOptions, logger: Logger, - getNavigatorContext: () => NavigatorContext + getNavigatorContext: () => NavigatorContext, + generationAnalyticsClient?: AnswerGenerationAnalyticsClient ) { const {renewAccessToken} = options.configuration; const renewTokenMiddleware = createRenewAccessTokenMiddleware( @@ -375,6 +379,8 @@ function createMiddleware( ); const generateAnswerListener = createGenerateAnswerListener({ getNavigatorContext, + generationAnalyticsClient: + generationAnalyticsClient ?? generatedAnswerAnalyticsClient, }); return [ diff --git a/packages/headless/src/app/listener-middleware/generate-answer-listener-middleware.test.ts b/packages/headless/src/app/listener-middleware/generate-answer-listener-middleware.test.ts index c86747f9b75..8561e158b70 100644 --- a/packages/headless/src/app/listener-middleware/generate-answer-listener-middleware.test.ts +++ b/packages/headless/src/app/listener-middleware/generate-answer-listener-middleware.test.ts @@ -67,6 +67,11 @@ describe('generateAnswerListener', () => { getDefaultMiddleware().prepend( createGenerateAnswerListener({ getNavigatorContext: buildMockNavigatorContextProvider(), + generationAnalyticsClient: { + logGeneratedAnswerStreamEnd: vi.fn(), + logGeneratedAnswerResponseLinked: vi.fn(), + // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- unit test + } as any, // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- unit test }).middleware as Middleware<{}, any> ), @@ -109,6 +114,11 @@ describe('generateAnswerListener', () => { getDefaultMiddleware().prepend( createGenerateAnswerListener({ getNavigatorContext: buildMockNavigatorContextProvider(), + generationAnalyticsClient: { + logGeneratedAnswerStreamEnd: vi.fn(), + logGeneratedAnswerResponseLinked: vi.fn(), + // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- unit test + } as any, // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- unit test }).middleware as Middleware<{}, any> ), @@ -181,6 +191,11 @@ describe('generateAnswerListener', () => { getDefaultMiddleware().prepend( createGenerateAnswerListener({ getNavigatorContext: buildMockNavigatorContextProvider(), + generationAnalyticsClient: { + logGeneratedAnswerStreamEnd: vi.fn(), + logGeneratedAnswerResponseLinked: vi.fn(), + // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- unit test + } as any, // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- unit test }).middleware as Middleware<{}, any> ), @@ -218,6 +233,11 @@ describe('generateAnswerListener', () => { getDefaultMiddleware().prepend( createGenerateAnswerListener({ getNavigatorContext: buildMockNavigatorContextProvider(), + generationAnalyticsClient: { + logGeneratedAnswerStreamEnd: vi.fn(), + logGeneratedAnswerResponseLinked: vi.fn(), + // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- unit test + } as any, // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- unit test }).middleware as Middleware<{}, any> ), diff --git a/packages/headless/src/app/listener-middleware/generate-answer-listener-middleware.ts b/packages/headless/src/app/listener-middleware/generate-answer-listener-middleware.ts index 5469e4cdd94..ef2b7e2a58d 100644 --- a/packages/headless/src/app/listener-middleware/generate-answer-listener-middleware.ts +++ b/packages/headless/src/app/listener-middleware/generate-answer-listener-middleware.ts @@ -2,6 +2,7 @@ import {createListenerMiddleware, type Dispatch} from '@reduxjs/toolkit'; import {createAnswerRunner} from '../../api/knowledge/answer-generation/agents/answer-agent/answer-agent-runner.js'; import {resetFollowUpAnswers} from '../../features/follow-up-answers/follow-up-answers-actions.js'; import {resetAnswer} from '../../features/generated-answer/generated-answer-actions.js'; +import type {AnswerGenerationAnalyticsClient} from '../../features/generated-answer/answer-generation-analytics-client.js'; import type {StateNeededForHeadAnswerParams} from '../../features/generated-answer/generated-answer-request.js'; import {isGeneratedAnswerFeatureEnabledWithAgentAPI} from '../../features/generated-answer/generated-answer-selectors.js'; import {selectQuery} from '../../features/query/query-selectors.js'; @@ -10,11 +11,15 @@ import type {NavigatorContext} from '../navigator-context-provider.js'; export const createGenerateAnswerListener = (extra: { getNavigatorContext: () => NavigatorContext; + generationAnalyticsClient: AnswerGenerationAnalyticsClient; }) => { const generateAnswerListener = createListenerMiddleware< StateNeededForHeadAnswerParams, Dispatch, - {getNavigatorContext: () => NavigatorContext} + { + getNavigatorContext: () => NavigatorContext; + generationAnalyticsClient: AnswerGenerationAnalyticsClient; + } >({extra}); const answerRunner = createAnswerRunner(); @@ -41,7 +46,12 @@ export const createGenerateAnswerListener = (extra: { return; } - answerRunner.run(state, listenerApi.dispatch, extra.getNavigatorContext); + answerRunner.run( + state, + listenerApi.dispatch, + extra.getNavigatorContext, + extra.generationAnalyticsClient + ); }, }); diff --git a/packages/headless/src/app/search-engine/search-engine.ts b/packages/headless/src/app/search-engine/search-engine.ts index 8cddbc905e7..1b4e517987c 100644 --- a/packages/headless/src/app/search-engine/search-engine.ts +++ b/packages/headless/src/app/search-engine/search-engine.ts @@ -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'; @@ -137,6 +138,7 @@ export function buildSearchEngine(options: SearchEngineOptions): SearchEngine { ...buildThunkExtraArguments(configuration, logger), apiClient: searchAPIClient, streamingClient: generatedAnswerClient, + answerGenerationAnalyticsClient: generatedAnswerAnalyticsClient, }; const augmentedOptions: EngineOptions = { diff --git a/packages/headless/src/app/thunk-extra-arguments.ts b/packages/headless/src/app/thunk-extra-arguments.ts index 0bb0653de98..b99f8cba66e 100644 --- a/packages/headless/src/app/thunk-extra-arguments.ts +++ b/packages/headless/src/app/thunk-extra-arguments.ts @@ -2,6 +2,7 @@ import type {Relay} from '@coveo/relay'; import type {AnalyticsClientSendEventHook} from 'coveo.analytics'; import type {Logger} from 'pino'; import type {GeneratedAnswerAPIClient} from '../api/generated-answer/generated-answer-client.js'; +import type {AnswerGenerationAnalyticsClient} from '../features/generated-answer/answer-generation-analytics-client.js'; import { NoopPreprocessRequest, type PreprocessRequest, @@ -25,6 +26,7 @@ export interface ThunkExtraArguments { analyticsClientMiddleware: AnalyticsClientSendEventHook; logger: Logger; validatePayload: typeof validatePayloadAndThrow; + answerGenerationAnalyticsClient?: AnswerGenerationAnalyticsClient; } export function buildThunkExtraArguments( diff --git a/packages/headless/src/controllers/core/generated-answer/headless-core-generated-answer.ts b/packages/headless/src/controllers/core/generated-answer/headless-core-generated-answer.ts index 244feaeed86..fe1f5c6166a 100644 --- a/packages/headless/src/controllers/core/generated-answer/headless-core-generated-answer.ts +++ b/packages/headless/src/controllers/core/generated-answer/headless-core-generated-answer.ts @@ -3,6 +3,7 @@ import type { CustomAction, LegacySearchAction, } from '../../../features/analytics/analytics-utils.js'; +import type {AnswerGenerationAnalyticsClient} from '../../../features/generated-answer/answer-generation-analytics-client.js'; import { closeGeneratedAnswerFeedbackModal, collapseGeneratedAnswer, @@ -134,7 +135,7 @@ export interface GeneratedAnswer extends Controller { ): void; } -export interface GeneratedAnswerAnalyticsClient { +export interface GeneratedAnswerAnalyticsClient extends AnswerGenerationAnalyticsClient { /** @deprecated */ logLikeGeneratedAnswer(): CustomAction; logLikeGeneratedAnswer(answerId?: string): CustomAction; diff --git a/packages/headless/src/controllers/knowledge/generated-answer/headless-generated-answer-with-follow-ups.test.ts b/packages/headless/src/controllers/knowledge/generated-answer/headless-generated-answer-with-follow-ups.test.ts index 97d81fd9fcd..c30c67da106 100644 --- a/packages/headless/src/controllers/knowledge/generated-answer/headless-generated-answer-with-follow-ups.test.ts +++ b/packages/headless/src/controllers/knowledge/generated-answer/headless-generated-answer-with-follow-ups.test.ts @@ -196,7 +196,10 @@ describe('GeneratedAnswerWithFollowUps', () => { 'org-123', 'prod' ); - expect(mockCreateFollowUpStrategy).toHaveBeenCalledWith(engine.dispatch); + expect(mockCreateFollowUpStrategy).toHaveBeenCalledWith( + engine.dispatch, + generatedAnswerAnalyticsClient + ); }); describe('state getter', () => { @@ -297,7 +300,8 @@ describe('GeneratedAnswerWithFollowUps', () => { expect(mockAnswerRunner.run).toHaveBeenCalledWith( engine.state, engine.dispatch, - expect.any(Function) + expect.any(Function), + generatedAnswerAnalyticsClient ); const navigatorContextProvider = mockAnswerRunner.run.mock.calls[0][2]; expect(navigatorContextProvider()).toBe(engine.navigatorContext); diff --git a/packages/headless/src/controllers/knowledge/generated-answer/headless-generated-answer-with-follow-ups.ts b/packages/headless/src/controllers/knowledge/generated-answer/headless-generated-answer-with-follow-ups.ts index 0ab31034481..30811e42df9 100644 --- a/packages/headless/src/controllers/knowledge/generated-answer/headless-generated-answer-with-follow-ups.ts +++ b/packages/headless/src/controllers/knowledge/generated-answer/headless-generated-answer-with-follow-ups.ts @@ -125,7 +125,10 @@ export function buildGeneratedAnswerWithFollowUps( organizationId, environment ); - const followUpStrategy = createFollowUpStrategy(engine.dispatch); + const followUpStrategy = createFollowUpStrategy( + engine.dispatch, + analyticsClient + ); const answerRunner = createAnswerRunner(); return { @@ -153,7 +156,8 @@ export function buildGeneratedAnswerWithFollowUps( answerRunner.run( engine.state, engine.dispatch, - () => engine.navigatorContext + () => engine.navigatorContext, + analyticsClient ); }, diff --git a/packages/headless/src/features/generated-answer/answer-generation-analytics-client.ts b/packages/headless/src/features/generated-answer/answer-generation-analytics-client.ts new file mode 100644 index 00000000000..c7fa4bb7980 --- /dev/null +++ b/packages/headless/src/features/generated-answer/answer-generation-analytics-client.ts @@ -0,0 +1,24 @@ +import type { + CustomAction, + InsightAction, +} from '../analytics/analytics-utils.js'; + +/** + * Analytics client for the answer generation lifecycle events. + * + * These events are dispatched from the answer generation machinery (the answer + * generation listener middleware and the answer/follow-up streaming strategies) + * rather than from the controller itself. Injecting this client allows each use + * case (search, insight, ...) to log the generation lifecycle with its own + * analytics implementation instead of being hardcoded to a single use case. + */ +export interface AnswerGenerationAnalyticsClient { + logGeneratedAnswerStreamEnd( + answerGenerated: boolean, + answerId?: string, + answerTextIsEmpty?: boolean + ): CustomAction | InsightAction; + logGeneratedAnswerResponseLinked( + answerId?: string + ): CustomAction | InsightAction; +} diff --git a/packages/headless/src/features/generated-answer/generated-answer-analytics-actions.ts b/packages/headless/src/features/generated-answer/generated-answer-analytics-actions.ts index 6be6fa426fc..a16b0d7988e 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-analytics-actions.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-analytics-actions.ts @@ -494,6 +494,7 @@ export const generatedAnswerAnalyticsClient = { logGeneratedAnswerHideAnswers, logGeneratedAnswerShowAnswers, logGeneratedAnswerStreamEnd, + logGeneratedAnswerResponseLinked, logGeneratedAnswerFeedback, logDislikeGeneratedAnswer, logLikeGeneratedAnswer, diff --git a/packages/headless/src/features/generated-answer/generated-answer-insight-analytics-actions.ts b/packages/headless/src/features/generated-answer/generated-answer-insight-analytics-actions.ts index 42a199d9d7a..49c532f622b 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-insight-analytics-actions.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-insight-analytics-actions.ts @@ -418,6 +418,7 @@ export const generatedAnswerInsightAnalyticsClient = { logGeneratedAnswerHideAnswers, logGeneratedAnswerShowAnswers, logGeneratedAnswerStreamEnd, + logGeneratedAnswerResponseLinked, logGeneratedAnswerFeedback, logDislikeGeneratedAnswer, logLikeGeneratedAnswer,