From 328df635a5e0984ddb71bf18c5cc2ebf461c423e Mon Sep 17 00:00:00 2001 From: Toni Devine Date: Fri, 16 Oct 2026 16:51:31 +0100 Subject: [PATCH] feat(core): Add telemetry for the WhatsApp channel setup (no-changelog) --- packages/@n8n/telemetry/src/events/agents.ts | 65 +++++++++ .../helpers/whatsapp/replay-test-context.ts | 5 + .../whatsapp/synthetic-integration.test.ts | 17 ++- .../whatsapp-setup-telemetry.service.test.ts | 33 +++++ .../whatsapp/whatsapp-integration.ts | 9 +- .../whatsapp-setup-telemetry.service.ts | 26 ++++ .../AgentChannelWhatsAppIdentityCard.vue | 3 +- .../AgentChannelWhatsAppSetup.test.ts | 138 ++++++++++++++++++ .../whatsapp/AgentChannelWhatsAppSetup.vue | 49 ++++++- .../agents/composables/useAgentTelemetry.ts | 46 ++++++ 10 files changed, 384 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/modules/agents/integrations/platforms/__tests__/whatsapp/whatsapp-setup-telemetry.service.test.ts create mode 100644 packages/cli/src/modules/agents/integrations/platforms/whatsapp/whatsapp-setup-telemetry.service.ts diff --git a/packages/@n8n/telemetry/src/events/agents.ts b/packages/@n8n/telemetry/src/events/agents.ts index 63ac3fe2ed0..4072a73e0b3 100644 --- a/packages/@n8n/telemetry/src/events/agents.ts +++ b/packages/@n8n/telemetry/src/events/agents.ts @@ -225,6 +225,19 @@ export const AGENTS_TELEMETRY = defineTelemetryEvents({ ), }), }, + AGENT_WHATSAPP_WEBHOOK_VERIFIED: { + name: 'Agent WhatsApp webhook verified', + description: + 'Meta completed the webhook handshake with an agent: its callback URL is reachable and the verify token matched. Shows how many WhatsApp setups get past the Meta webhook step. Meta repeats the handshake each time someone clicks Verify and save, so one agent can emit this more than once.', + properties: z.object({ + agent_id: z.string(), + phase: z + .enum(['setup', 'connected']) + .describe( + 'setup: the channel was not connected yet, so n8n answered without a credential; connected: the running channel answered, which is a re-verification', + ), + }), + }, USER_RESPONDED_TO_AGENT_TOOL_APPROVAL: { name: 'User responded to agent tool approval', description: @@ -733,6 +746,58 @@ export const AGENTS_TELEMETRY = defineTelemetryEvents({ session_id: sessionId, }), }, + USER_CHECKED_WHATSAPP_CHANNEL_CREDENTIAL: { + name: 'User checked WhatsApp channel credential', + description: + 'The WhatsApp channel setup checked the selected credential against Meta. Runs on its own whenever the selected credential changes, and when the user clicks Check again. Shows why credentials fail on the way to a connected channel.', + properties: z.object({ + agent_id: z.string(), + trigger: z + .enum(['auto', 'recheck']) + .describe('auto: the selected credential changed; recheck: the user clicked Check again'), + status: z.enum(['ok', 'failed']), + reason: z + .enum([ + 'incomplete', + 'temporary_token', + 'mismatch', + 'unreachable', + 'not_found', + 'request_failed', + ]) + .optional() + .describe( + 'Only when status is failed. not_found: the user cannot use that credential; request_failed: the request to n8n failed, so Meta was never asked', + ), + session_id: sessionId, + }), + }, + USER_CLICKED_WHATSAPP_CHANNEL_LINK: { + name: 'User clicked WhatsApp channel link', + description: + 'The user clicked a link in the WhatsApp channel setup or settings that leaves n8n for Meta. Shows where people go to Meta, and whether they need to go back for something the setup could have told them.', + properties: z.object({ + agent_id: z.string(), + target: z + .enum(['meta_dashboard', 'whatsapp_manager', 'meta_app']) + .describe( + 'meta_dashboard: Open Meta App Dashboard in the setup; whatsapp_manager: Edit in WhatsApp Manager in the settings; meta_app: Find your Meta app in the settings', + ), + session_id: sessionId, + }), + }, + USER_SAW_WHATSAPP_REVIEW_STATE: { + name: 'User saw WhatsApp review state', + description: + "The WhatsApp channel settings showed a callout about Meta's review of the phone number. Fires when the settings open with a review open or declined, and again when the state changes, never on every poll. Shows how often Meta declines or restricts a number.", + properties: z.object({ + agent_id: z.string(), + state: z + .enum(['pending_verification', 'pending_name_review', 'name_declined', 'restricted']) + .describe('Which review state the callout described'), + session_id: sessionId, + }), + }, USER_CLICKED_DEPLOY_TO_AZURE_FOR_TEAMS_CHANNEL: { name: 'User clicked Deploy to Azure for Teams channel', description: 'The user clicked the Deploy to Azure button in the Teams channel setup.', diff --git a/packages/cli/src/modules/agents/integrations/__tests__/helpers/whatsapp/replay-test-context.ts b/packages/cli/src/modules/agents/integrations/__tests__/helpers/whatsapp/replay-test-context.ts index 4d1304e88dc..51e1c8368eb 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/helpers/whatsapp/replay-test-context.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/helpers/whatsapp/replay-test-context.ts @@ -19,6 +19,7 @@ import type { } from '../../../integration-tools'; import type { AgentRepository } from '../../../../repositories/agent.repository'; import { WhatsAppIntegration } from '../../../platforms/whatsapp/whatsapp-integration'; +import type { WhatsAppSetupTelemetryService } from '../../../platforms/whatsapp/whatsapp-setup-telemetry.service'; import type { WhatsAppWebhookVerificationStore } from '../../../platforms/whatsapp/whatsapp-webhook-verification.store'; import { createMockAttachmentService, @@ -140,6 +141,7 @@ export function whatsAppThreadId( export function createWhatsAppIntegration( channelRateLimitGuard = new ChannelRateLimitGuard(), webhookVerification: WhatsAppWebhookVerificationStore = mock(), + setupTelemetry: WhatsAppSetupTelemetryService = mock(), ): WhatsAppIntegration { return new WhatsAppIntegration( mock(), @@ -150,6 +152,7 @@ export function createWhatsAppIntegration( }), channelRateLimitGuard, webhookVerification, + setupTelemetry, ); } @@ -289,6 +292,7 @@ export async function createWhatsAppReplayContext( failedApiTypes?: string[]; failureSequence?: WhatsAppFailureSequence; webhookVerification?: WhatsAppWebhookVerificationStore; + setupTelemetry?: WhatsAppSetupTelemetryService; } = {}, ): Promise { const stub = installWhatsAppApiStub(options.failedApiTypes, options.failureSequence); @@ -301,6 +305,7 @@ export async function createWhatsAppReplayContext( const integrationImpl = createWhatsAppIntegration( channelRateLimitGuard, options.webhookVerification, + options.setupTelemetry, ); const integration: AgentIntegrationConfig = options.integration ?? { type: 'whatsapp', diff --git a/packages/cli/src/modules/agents/integrations/platforms/__tests__/whatsapp/synthetic-integration.test.ts b/packages/cli/src/modules/agents/integrations/platforms/__tests__/whatsapp/synthetic-integration.test.ts index 52caf57295f..b6275211bd0 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/__tests__/whatsapp/synthetic-integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/__tests__/whatsapp/synthetic-integration.test.ts @@ -28,6 +28,7 @@ import { whatsAppReplayFixtures, whatsAppWebhook, } from '../../../__tests__/helpers/whatsapp/synthetic-fixtures'; +import type { WhatsAppSetupTelemetryService } from '../../whatsapp/whatsapp-setup-telemetry.service'; import type { WhatsAppWebhookVerificationStore } from '../../whatsapp/whatsapp-webhook-verification.store'; // The chat SDK + adapters are ESM-only. Production loads them via esm-loader's @@ -1095,8 +1096,10 @@ describe('WhatsApp Cloud API integration scenarios', () => { it('records the handshake when the token matches', async () => { const webhookVerification = mock(); + const setupTelemetry = mock(); const ctx = await createWhatsAppReplayContext(whatsAppReplayFixtures(), { webhookVerification, + setupTelemetry, }); try { @@ -1105,6 +1108,10 @@ describe('WhatsApp Cloud API integration scenarios', () => { expect(response.status).toBe(200); expect(await response.text()).toBe('12345'); expect(webhookVerification.markVerified).toHaveBeenCalledExactlyOnceWith('agent-1'); + expect(setupTelemetry.webhookVerified).toHaveBeenCalledExactlyOnceWith( + 'agent-1', + 'connected', + ); } finally { await ctx.shutdown(); } @@ -1112,8 +1119,10 @@ describe('WhatsApp Cloud API integration scenarios', () => { it('does not record a rejected handshake', async () => { const webhookVerification = mock(); + const setupTelemetry = mock(); const ctx = await createWhatsAppReplayContext(whatsAppReplayFixtures(), { webhookVerification, + setupTelemetry, }); try { @@ -1121,6 +1130,7 @@ describe('WhatsApp Cloud API integration scenarios', () => { expect(response.status).toBe(403); expect(webhookVerification.markVerified).not.toHaveBeenCalled(); + expect(setupTelemetry.webhookVerified).not.toHaveBeenCalled(); } finally { await ctx.shutdown(); } @@ -1135,7 +1145,8 @@ describe('WhatsApp Cloud API integration scenarios', () => { it("answers Meta's handshake with the raw challenge when the token matches", () => { const webhookVerification = mock(); - const integration = createWhatsAppIntegration(undefined, webhookVerification); + const setupTelemetry = mock(); + const integration = createWhatsAppIntegration(undefined, webhookVerification, setupTelemetry); const result = integration.handleUnauthenticatedWebhook({ agentId: 'agent-1', @@ -1151,11 +1162,13 @@ describe('WhatsApp Cloud API integration scenarios', () => { expect(result).toEqual({ status: 200, body: '12345', raw: true }); expect(webhookVerification.markVerified).toHaveBeenCalledExactlyOnceWith('agent-1'); + expect(setupTelemetry.webhookVerified).toHaveBeenCalledExactlyOnceWith('agent-1', 'setup'); }); it('rejects the handshake when the verify token does not match', () => { const webhookVerification = mock(); - const integration = createWhatsAppIntegration(undefined, webhookVerification); + const setupTelemetry = mock(); + const integration = createWhatsAppIntegration(undefined, webhookVerification, setupTelemetry); const result = integration.handleUnauthenticatedWebhook({ agentId: 'agent-1', diff --git a/packages/cli/src/modules/agents/integrations/platforms/__tests__/whatsapp/whatsapp-setup-telemetry.service.test.ts b/packages/cli/src/modules/agents/integrations/platforms/__tests__/whatsapp/whatsapp-setup-telemetry.service.test.ts new file mode 100644 index 00000000000..b186b29c720 --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/__tests__/whatsapp/whatsapp-setup-telemetry.service.test.ts @@ -0,0 +1,33 @@ +import { TELEMETRY_EVENT } from '@n8n/telemetry'; +import { mock } from 'vitest-mock-extended'; + +import type { Telemetry } from '@/telemetry'; + +import { WhatsAppSetupTelemetryService } from '../../whatsapp/whatsapp-setup-telemetry.service'; + +describe('WhatsAppSetupTelemetryService', () => { + let telemetry: ReturnType>; + let service: WhatsAppSetupTelemetryService; + + beforeEach(() => { + telemetry = mock(); + service = new WhatsAppSetupTelemetryService(telemetry); + }); + + it.each(['setup', 'connected'] as const)('reports a %s handshake', (phase) => { + service.webhookVerified('agent-1', phase); + + expect(telemetry.track).toHaveBeenCalledExactlyOnceWith( + TELEMETRY_EVENT.AGENTS.AGENT_WHATSAPP_WEBHOOK_VERIFIED, + { agent_id: 'agent-1', phase }, + ); + }); + + it('does not throw when reporting fails, so Meta still gets its answer', () => { + telemetry.track.mockImplementation(() => { + throw new Error('transport down'); + }); + + expect(() => service.webhookVerified('agent-1', 'setup')).not.toThrow(); + }); +}); diff --git a/packages/cli/src/modules/agents/integrations/platforms/whatsapp/whatsapp-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/whatsapp/whatsapp-integration.ts index c2f46a262d0..35e56696e95 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/whatsapp/whatsapp-integration.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/whatsapp/whatsapp-integration.ts @@ -41,6 +41,7 @@ import { deriveWhatsAppVerifyToken, stringProperty, stringValue } from '../../in import { resolveIntegrationActionDefinitions } from '../../integration-tool-definitions'; import { buildIntegrationConnectionId } from '../../integration-tool-factory'; import { startTypingIndicator } from '../typing-indicator'; +import { WhatsAppSetupTelemetryService } from './whatsapp-setup-telemetry.service'; import { WhatsAppWebhookVerificationStore } from './whatsapp-webhook-verification.store'; type ChatSdk = Awaited>; @@ -194,6 +195,7 @@ export class WhatsAppIntegration extends AgentChatIntegration { private readonly instanceSettings: InstanceSettings, private readonly channelRateLimitGuard: ChannelRateLimitGuard, private readonly webhookVerification: WhatsAppWebhookVerificationStore, + private readonly setupTelemetry: WhatsAppSetupTelemetryService, ) { super(); } @@ -242,6 +244,7 @@ export class WhatsAppIntegration extends AgentChatIntegration { // Recorded so the setup can confirm the step. Not awaited, because Meta // times out the handshake and the record is only for display. void this.webhookVerification.markVerified(context.agentId); + this.setupTelemetry.webhookVerified(context.agentId, 'setup'); return { status: 200, body: challenge, raw: true }; } @@ -298,6 +301,7 @@ export class WhatsAppIntegration extends AgentChatIntegration { // retry cycle against an already-limited number instead of failing fast. const connectionId = buildIntegrationConnectionId(ctx.integration); const webhookVerification = this.webhookVerification; + const setupTelemetry = this.setupTelemetry; const agentId = ctx.agentId; // Wraps the real adapter to enforce the 24-hour customer service window @@ -310,7 +314,10 @@ export class WhatsAppIntegration extends AgentChatIntegration { // the same agent still confirms the step. protected override handleVerificationChallenge(request: Request): Response { const response = super.handleVerificationChallenge(request); - if (response.status === 200) void webhookVerification.markVerified(agentId); + if (response.status === 200) { + void webhookVerification.markVerified(agentId); + setupTelemetry.webhookVerified(agentId, 'connected'); + } return response; } diff --git a/packages/cli/src/modules/agents/integrations/platforms/whatsapp/whatsapp-setup-telemetry.service.ts b/packages/cli/src/modules/agents/integrations/platforms/whatsapp/whatsapp-setup-telemetry.service.ts new file mode 100644 index 00000000000..ca115b770af --- /dev/null +++ b/packages/cli/src/modules/agents/integrations/platforms/whatsapp/whatsapp-setup-telemetry.service.ts @@ -0,0 +1,26 @@ +import { Service } from '@n8n/di'; +import { TELEMETRY_EVENT } from '@n8n/telemetry'; + +import { Telemetry } from '@/telemetry'; + +/** + * Reports how far a WhatsApp setup got on the n8n side. The credential step is + * reported by the editor, because only it knows when a check was a re-check. + * Meta's handshake reaches the backend directly, so it is reported here. + */ +@Service() +export class WhatsAppSetupTelemetryService { + constructor(private readonly telemetry: Telemetry) {} + + /** Never throws: Meta has already received its answer when this runs. */ + webhookVerified(agentId: string, phase: 'setup' | 'connected'): void { + try { + this.telemetry.track(TELEMETRY_EVENT.AGENTS.AGENT_WHATSAPP_WEBHOOK_VERIFIED, { + agent_id: agentId, + phase, + }); + } catch { + // A failed report must not change the answer Meta gets. + } + } +} diff --git a/packages/frontend/editor-ui/src/features/agents/channels/whatsapp/AgentChannelWhatsAppIdentityCard.vue b/packages/frontend/editor-ui/src/features/agents/channels/whatsapp/AgentChannelWhatsAppIdentityCard.vue index 15ab5bb452e..9dfd5f197b1 100644 --- a/packages/frontend/editor-ui/src/features/agents/channels/whatsapp/AgentChannelWhatsAppIdentityCard.vue +++ b/packages/frontend/editor-ui/src/features/agents/channels/whatsapp/AgentChannelWhatsAppIdentityCard.vue @@ -27,7 +27,7 @@ const props = withDefaults( }, ); -const emit = defineEmits<{ connect: [] }>(); +const emit = defineEmits<{ connect: []; openManager: [] }>(); /** The profile is edited in Meta, not in n8n. */ const WHATSAPP_MANAGER_URL = 'https://business.facebook.com/latest/whatsapp_manager/phone_numbers/'; @@ -94,6 +94,7 @@ const subtitle = computed(() => { variant="outline" size="medium" data-testid="whatsapp-manager-link" + @click="emit('openManager')" > {{ i18n.baseText('agents.channels.whatsapp.settings.editInManager') }} diff --git a/packages/frontend/editor-ui/src/features/agents/channels/whatsapp/AgentChannelWhatsAppSetup.test.ts b/packages/frontend/editor-ui/src/features/agents/channels/whatsapp/AgentChannelWhatsAppSetup.test.ts index 35884909862..93912addcbd 100644 --- a/packages/frontend/editor-ui/src/features/agents/channels/whatsapp/AgentChannelWhatsAppSetup.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/channels/whatsapp/AgentChannelWhatsAppSetup.test.ts @@ -8,6 +8,7 @@ import { configure, fireEvent, waitFor } from '@testing-library/vue'; import { flushPromises } from '@vue/test-utils'; import { defineComponent, ref } from 'vue'; import type { WhatsAppCredentialCheck } from '@n8n/api-types'; +import { TELEMETRY_EVENT } from '@n8n/telemetry'; import AgentChannelWhatsAppSetup from './AgentChannelWhatsAppSetup.vue'; import { ResponseError } from '@n8n/rest-api-client'; @@ -34,6 +35,11 @@ vi.mock('@n8n/design-system', async (importOriginal) => ({ }, })); +const { trackMock } = vi.hoisted(() => ({ trackMock: vi.fn() })); +vi.mock('@n8n/composables/useTelemetry', () => ({ + useTelemetry: () => ({ track: trackMock }), +})); + const { showMessage, showError } = vi.hoisted(() => ({ showMessage: vi.fn(), showError: vi.fn() })); vi.mock('@n8n/composables/useToast', () => ({ useToast: () => ({ showMessage, showError }), @@ -705,6 +711,138 @@ describe('AgentChannelWhatsAppSetup', () => { }); }); + describe('telemetry', () => { + const EVENT = TELEMETRY_EVENT.AGENTS.USER_CHECKED_WHATSAPP_CHANNEL_CREDENTIAL; + + it('tracks a credential that checks out', async () => { + renderComponent({ props: props({ modelValue: 'cred-1' }) }); + + await waitFor(() => + expect(trackMock).toHaveBeenCalledWith( + EVENT, + expect.objectContaining({ agent_id: 'a', trigger: 'auto', status: 'ok' }), + ), + ); + }); + + it.each([ + ['incomplete', 'incomplete'], + ['temporaryToken', 'temporary_token'], + ['mismatch', 'mismatch'], + ['unreachable', 'unreachable'], + ] as const)('tracks a %s credential as %s', async (reason, tracked) => { + vi.mocked(checkWhatsAppCredential).mockResolvedValue({ status: 'failed', reason }); + + renderComponent({ props: props({ modelValue: 'cred-1' }) }); + + await waitFor(() => + expect(trackMock).toHaveBeenCalledWith( + EVENT, + expect.objectContaining({ status: 'failed', reason: tracked }), + ), + ); + }); + + it('tracks a credential the caller cannot use as not found', async () => { + vi.mocked(checkWhatsAppCredential).mockRejectedValue( + new ResponseError('missing', { httpStatusCode: 404 }), + ); + + renderComponent({ props: props({ modelValue: 'cred-1' }) }); + + await waitFor(() => + expect(trackMock).toHaveBeenCalledWith( + EVENT, + expect.objectContaining({ status: 'failed', reason: 'not_found' }), + ), + ); + }); + + it('tracks a failed request to n8n apart from Meta being unreachable', async () => { + vi.mocked(checkWhatsAppCredential).mockRejectedValue(new Error('offline')); + + renderComponent({ props: props({ modelValue: 'cred-1' }) }); + + await waitFor(() => + expect(trackMock).toHaveBeenCalledWith( + EVENT, + expect.objectContaining({ status: 'failed', reason: 'request_failed' }), + ), + ); + }); + + it('tracks a recheck as one the user asked for', async () => { + vi.mocked(checkWhatsAppCredential).mockResolvedValue({ + status: 'failed', + reason: 'mismatch', + }); + const { getByTestId } = renderComponent({ props: props({ modelValue: 'cred-1' }) }); + await waitFor(() => expect(getByTestId('whatsapp-credential-recheck')).toBeVisible()); + trackMock.mockClear(); + + await fireEvent.click(getByTestId('whatsapp-credential-recheck')); + + await waitFor(() => + expect(trackMock).toHaveBeenCalledWith( + EVENT, + expect.objectContaining({ trigger: 'recheck', status: 'failed', reason: 'mismatch' }), + ), + ); + }); + + describe('links to Meta', () => { + const LINK_EVENT = TELEMETRY_EVENT.AGENTS.USER_CLICKED_WHATSAPP_CHANNEL_LINK; + + it('tracks the Meta App Dashboard button in the setup', async () => { + const { getByTestId } = renderComponent({ props: props() }); + await flushPromises(); + + await fireEvent.click(getByTestId('whatsapp-meta-dashboard-link')); + + expect(trackMock).toHaveBeenCalledWith( + LINK_EVENT, + expect.objectContaining({ agent_id: 'a', target: 'meta_dashboard' }), + ); + }); + + it.each([ + ['whatsapp-manager-link', 'whatsapp_manager'], + ['whatsapp-find-meta-app', 'meta_app'], + ])('tracks %s in the settings as %s', async (testId, target) => { + const { getByTestId } = renderComponent({ + props: props({ mode: 'edit', connected: true, modelValue: 'cred-1' }), + }); + await waitFor(() => expect(getByTestId(testId)).toBeVisible()); + + await fireEvent.click(getByTestId(testId)); + + expect(trackMock).toHaveBeenCalledWith( + LINK_EVENT, + expect.objectContaining({ agent_id: 'a', target }), + ); + }); + }); + + it('does not track the settings, which check again on every open', async () => { + renderComponent({ + props: props({ mode: 'edit', connected: true, modelValue: 'cred-1' }), + }); + await flushPromises(); + + expect(trackMock).not.toHaveBeenCalledWith(EVENT, expect.anything()); + }); + + it('does not send the credential or the profile', async () => { + renderComponent({ props: props({ modelValue: 'cred-1' }) }); + await waitFor(() => expect(trackMock).toHaveBeenCalledWith(EVENT, expect.anything())); + + const payload = JSON.stringify(trackMock.mock.calls); + expect(payload).not.toContain('cred-1'); + expect(payload).not.toContain(PROFILE.verifiedName); + expect(payload).not.toContain(PROFILE.displayPhoneNumber); + }); + }); + describe('connect', () => { const NEEDS = 'agents.channels.whatsapp.setup.connect'; diff --git a/packages/frontend/editor-ui/src/features/agents/channels/whatsapp/AgentChannelWhatsAppSetup.vue b/packages/frontend/editor-ui/src/features/agents/channels/whatsapp/AgentChannelWhatsAppSetup.vue index 8a5b37566f7..97a2a0c6af5 100644 --- a/packages/frontend/editor-ui/src/features/agents/channels/whatsapp/AgentChannelWhatsAppSetup.vue +++ b/packages/frontend/editor-ui/src/features/agents/channels/whatsapp/AgentChannelWhatsAppSetup.vue @@ -19,6 +19,7 @@ import { } from '@/features/credentials/credentials.store'; import { TIME } from '@/app/constants'; import { getWhatsAppVerifyToken } from '../../composables/useAgentApi'; +import { useAgentTelemetry } from '../../composables/useAgentTelemetry'; import AgentIntegrationCredentialConnection from '../../components/AgentIntegrationCredentialConnection.vue'; import type { AgentCredentialOption } from '../../components/AgentCredentialSelect.vue'; import AgentChannelWhatsAppIdentityCard from './AgentChannelWhatsAppIdentityCard.vue'; @@ -76,6 +77,7 @@ const emit = defineEmits<{ const i18n = useI18n(); const rootStore = useRootStore(); const toast = useToast(); +const agentTelemetry = useAgentTelemetry(); const META_APP_DASHBOARD_URL = 'https://developers.facebook.com/apps'; @@ -142,7 +144,35 @@ const profile = computed(() => credentialCheck.value?.status === 'ok' ? credentialCheck.value.profile : null, ); -async function runCredentialCheck() { +type CredentialCheckFailure = Extract['reason']; + +/** The names the warehouse knows the reasons by, apart from how the view words them. */ +const CHECK_FAILURE_REASON = { + incomplete: 'incomplete', + temporaryToken: 'temporary_token', + mismatch: 'mismatch', + unreachable: 'unreachable', + notFound: 'not_found', +} as const satisfies Record; + +/** Only the setup is measured: the settings check the credential again on every open. */ +function trackCredentialCheck( + trigger: 'auto' | 'recheck', + result: CredentialCheckResult, + requestFailed: boolean, +) { + if (props.mode !== 'setup') return; + agentTelemetry.trackCheckedWhatsAppCredential({ + agentId: props.agentId, + trigger, + status: result.status, + ...(result.status === 'failed' + ? { reason: requestFailed ? 'request_failed' : CHECK_FAILURE_REASON[result.reason] } + : {}), + }); +} + +async function runCredentialCheck(trigger: 'auto' | 'recheck' = 'auto') { const request = ++latestCheck; const id = credentialId.value; // A result for the credential just replaced says nothing about this one. @@ -159,6 +189,7 @@ async function runCredentialCheck() { if (request !== latestCheck || unmounted) return; checking.value = false; credentialCheck.value = { status: 'failed', reason: 'unreachable' }; + trackCredentialCheck(trigger, credentialCheck.value, true); toast.showError(error, i18n.baseText('agents.channels.modal.saveChannelError')); return; } @@ -168,6 +199,9 @@ async function runCredentialCheck() { } let result: CredentialCheckResult; + // Shown as unreachable, but kept apart in telemetry: a failed request to n8n says + // nothing about whether Meta accepts the credential. + let requestFailed = false; try { result = await checkWhatsAppCredential( rootStore.restApiContext, @@ -178,13 +212,15 @@ async function runCredentialCheck() { } catch (error) { const notFound = error instanceof ResponseError && error.httpStatusCode === 404; result = { status: 'failed', reason: notFound ? 'notFound' : 'unreachable' }; + requestFailed = !notFound; } if (request !== latestCheck) return; credentialCheck.value = result; checking.value = false; + trackCredentialCheck(trigger, result, requestFailed); } -watch(credentialId, runCredentialCheck, { immediate: true }); +watch(credentialId, () => runCredentialCheck(), { immediate: true }); // Saving the picked credential keeps its ID, so the watch above does not see the edit. // Pinia removes this listener when the component unmounts. @@ -427,6 +463,9 @@ defineExpose({ credentialId, currentSettings, validationError: null, afterSave } size="medium" icon="meta" data-testid="whatsapp-meta-dashboard-link" + @click=" + agentTelemetry.trackClickedWhatsAppLink({ agentId, target: 'meta_dashboard' }) + " > {{ i18n.baseText('agents.channels.whatsapp.setup.createApp.button') }} @@ -479,7 +518,7 @@ defineExpose({ credentialId, currentSettings, validationError: null, afterSave } variant="ghost" size="small" data-testid="whatsapp-credential-recheck" - @click="runCredentialCheck" + @click="runCredentialCheck('recheck')" > {{ i18n.baseText('agents.channels.whatsapp.setup.credential.recheck') }} @@ -619,6 +658,9 @@ defineExpose({ credentialId, currentSettings, validationError: null, afterSave } :personalisation="personalisation" :ready="credentialVerified" show-manager-link + @open-manager=" + agentTelemetry.trackClickedWhatsAppLink({ agentId, target: 'whatsapp_manager' }) + " /> {{ i18n.baseText('agents.channels.whatsapp.settings.findMetaApp') }} diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentTelemetry.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentTelemetry.ts index 2906fd63f81..c4ac97ac20c 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentTelemetry.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentTelemetry.ts @@ -174,6 +174,49 @@ export function useAgentTelemetry() { }); } + function trackCheckedWhatsAppCredential(params: { + agentId: string; + trigger: 'auto' | 'recheck'; + status: 'ok' | 'failed'; + reason?: InferTelemetryProps< + typeof TELEMETRY_EVENT.AGENTS.USER_CHECKED_WHATSAPP_CHANNEL_CREDENTIAL + >['reason']; + }) { + safeTrack(TELEMETRY_EVENT.AGENTS.USER_CHECKED_WHATSAPP_CHANNEL_CREDENTIAL, { + agent_id: params.agentId, + trigger: params.trigger, + status: params.status, + ...(params.reason ? { reason: params.reason } : {}), + ...common(), + }); + } + + function trackClickedWhatsAppLink(params: { + agentId: string; + target: InferTelemetryProps< + typeof TELEMETRY_EVENT.AGENTS.USER_CLICKED_WHATSAPP_CHANNEL_LINK + >['target']; + }) { + safeTrack(TELEMETRY_EVENT.AGENTS.USER_CLICKED_WHATSAPP_CHANNEL_LINK, { + agent_id: params.agentId, + target: params.target, + ...common(), + }); + } + + function trackSawWhatsAppReviewState(params: { + agentId: string; + state: InferTelemetryProps< + typeof TELEMETRY_EVENT.AGENTS.USER_SAW_WHATSAPP_REVIEW_STATE + >['state']; + }) { + safeTrack(TELEMETRY_EVENT.AGENTS.USER_SAW_WHATSAPP_REVIEW_STATE, { + agent_id: params.agentId, + state: params.state, + ...common(), + }); + } + function trackClickedDeployToAzure(params: { agentId: string }) { safeTrack(TELEMETRY_EVENT.AGENTS.USER_CLICKED_DEPLOY_TO_AZURE_FOR_TEAMS_CHANNEL, { agent_id: params.agentId, @@ -268,6 +311,9 @@ export function useAgentTelemetry() { trackClosedChannelSetup, trackFailedToConnectChannel, trackCheckedTeamsCredential, + trackCheckedWhatsAppCredential, + trackClickedWhatsAppLink, + trackSawWhatsAppReviewState, trackClickedDeployToAzure, trackDownloadedTeamsAppPackage, trackSelectedN8nChatAgent,