From 6467d95126744c72b8f230abfede9dea2823834c Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:29:40 +0000 Subject: [PATCH 1/2] feat(cdp): put the run id on every email engagement event Co-Authored-By: Claude Opus 5.5 --- .../messaging/email-tracking.service.test.ts | 33 ++++++++++++++++++- .../messaging/email-tracking.service.ts | 1 + .../services/messaging/email.service.test.ts | 1 + .../cdp/services/messaging/email.service.ts | 1 + 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/nodejs/src/cdp/services/messaging/email-tracking.service.test.ts b/nodejs/src/cdp/services/messaging/email-tracking.service.test.ts index c76983c6f596..0822e3f68944 100644 --- a/nodejs/src/cdp/services/messaging/email-tracking.service.test.ts +++ b/nodejs/src/cdp/services/messaging/email-tracking.service.test.ts @@ -1,6 +1,6 @@ import { createMockJobQueue } from '~/tests/helpers/mocks/job-queue.mock' import { mockProducerObserver } from '~/tests/helpers/mocks/producer.mock' -import { mockFetch } from '~/tests/helpers/mocks/request.mock' +import { mockFetch, mockInternalFetch } from '~/tests/helpers/mocks/request.mock' import { Server } from 'http' import supertest from 'supertest' @@ -10,6 +10,7 @@ import { FixtureHogFlowBuilder } from '~/cdp/_tests/builders/hogflow.builder' import { insertHogFunction } from '~/cdp/_tests/fixtures' import { insertHogFlow } from '~/cdp/_tests/fixtures-hogflows' import { CdpApi } from '~/cdp/cdp-api' +import { TeamWorkflowsConfigService } from '~/cdp/services/managers/team-workflows-config.service' import { CyclotronJobInvocationHogFunction, HogFunctionType } from '~/cdp/types' import { setupExpressApp } from '~/common/api/router' import { defaultConfig } from '~/common/config/config' @@ -17,6 +18,7 @@ import { KAFKA_APP_METRICS_2, KAFKA_LOG_ENTRIES } from '~/common/config/kafka-to import { closeHub, createHub } from '~/common/utils/db/hub' import { PostgresUse } from '~/common/utils/db/postgres' import * as envUtils from '~/common/utils/env-utils' +import { parseJSON } from '~/common/utils/json-parse' import { createCdpConsumerDeps } from '~/tests/helpers/cdp' import { waitForExpect } from '~/tests/helpers/expectations' import { createTestTeamFixture } from '~/tests/helpers/sql' @@ -44,6 +46,7 @@ describe('EmailTrackingService', () => { team = (await createTestTeamFixture(hub.postgres)).team mockFetch.mockClear() + mockInternalFetch.mockClear() mockProducerObserver.resetKafkaProducer() }) @@ -364,6 +367,34 @@ describe('EmailTrackingService', () => { } ) + it('should capture the click on the recipient with the run id of the send', async () => { + jest.spyOn(TeamWorkflowsConfigService.prototype, 'shouldCaptureEngagementEvents').mockResolvedValueOnce( + true + ) + const link = new URL( + signer.redirectUrl( + { functionId: hogFunction.id, id: invocationId, teamId: team.id, distinctId: 'recipient' }, + 'https://example.com/' + ) + ) + + const res = await supertest(app).get(`${link.pathname}${link.search}`) + expect(res.status).toBe(302) + + await waitForExpect(() => { + const captured = mockInternalFetch.mock.calls.map(([, options]) => + parseJSON(options!.body as string) + ) + expect(captured).toEqual([ + expect.objectContaining({ + event: '$workflows_email_link_clicked', + distinct_id: 'recipient', + properties: expect.objectContaining({ $workflow_run_id: invocationId }), + }), + ]) + }) + }) + it('should return 404 for an unsigned link after the unsigned link sunset', async () => { setClock(UNSIGNED_REDIRECT_SUNSET) const res = await supertest(app).get('/public/m/redirect?target=https://other.example/') diff --git a/nodejs/src/cdp/services/messaging/email-tracking.service.ts b/nodejs/src/cdp/services/messaging/email-tracking.service.ts index 645866c70885..e46500c5f7aa 100644 --- a/nodejs/src/cdp/services/messaging/email-tracking.service.ts +++ b/nodejs/src/cdp/services/messaging/email-tracking.service.ts @@ -395,6 +395,7 @@ export class EmailTrackingService { properties: { $workflow_id: appSourceId, $workflow_action_id: actionId, + $workflow_run_id: invocationId, ...(workflowVersion !== undefined ? { $workflow_version: workflowVersion } : {}), ...properties, }, diff --git a/nodejs/src/cdp/services/messaging/email.service.test.ts b/nodejs/src/cdp/services/messaging/email.service.test.ts index 7c3cd05338a0..37800dd23c53 100644 --- a/nodejs/src/cdp/services/messaging/email.service.test.ts +++ b/nodejs/src/cdp/services/messaging/email.service.test.ts @@ -1838,6 +1838,7 @@ describe('EmailService', () => { properties: { $workflow_id: invocation.functionId, $workflow_action_id: invocation.state.actionId, + $workflow_run_id: invocation.id, $email_to: 'test@example.com', $email_tracking_enabled: true, }, diff --git a/nodejs/src/cdp/services/messaging/email.service.ts b/nodejs/src/cdp/services/messaging/email.service.ts index ff83d8746de4..e902c7b076eb 100644 --- a/nodejs/src/cdp/services/messaging/email.service.ts +++ b/nodejs/src/cdp/services/messaging/email.service.ts @@ -737,6 +737,7 @@ export class EmailService { properties: { $workflow_id: invocation.functionId, $workflow_action_id: invocation.state.actionId, + $workflow_run_id: invocation.id, $email_to: params.to.email, // A sensitive template's subject can carry a login code, so its events leave it out. ...(invocation.hogFunction?.metadata?.sensitive === true ? {} : { $email_subject: params.subject }), From 9187830a5bf9485d1b81107f627ac160eff9ac6d Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:22:59 +0000 Subject: [PATCH 2/2] chore(cdp): restore the engagement capture spy after the run id test Co-Authored-By: Claude Opus 5.5 --- .../messaging/email-tracking.service.test.ts | 50 ++++++++++--------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/nodejs/src/cdp/services/messaging/email-tracking.service.test.ts b/nodejs/src/cdp/services/messaging/email-tracking.service.test.ts index 0822e3f68944..d95f16aacb95 100644 --- a/nodejs/src/cdp/services/messaging/email-tracking.service.test.ts +++ b/nodejs/src/cdp/services/messaging/email-tracking.service.test.ts @@ -368,31 +368,35 @@ describe('EmailTrackingService', () => { ) it('should capture the click on the recipient with the run id of the send', async () => { - jest.spyOn(TeamWorkflowsConfigService.prototype, 'shouldCaptureEngagementEvents').mockResolvedValueOnce( - true - ) - const link = new URL( - signer.redirectUrl( - { functionId: hogFunction.id, id: invocationId, teamId: team.id, distinctId: 'recipient' }, - 'https://example.com/' + const captureSpy = jest + .spyOn(TeamWorkflowsConfigService.prototype, 'shouldCaptureEngagementEvents') + .mockResolvedValue(true) + try { + const link = new URL( + signer.redirectUrl( + { functionId: hogFunction.id, id: invocationId, teamId: team.id, distinctId: 'recipient' }, + 'https://example.com/' + ) ) - ) - - const res = await supertest(app).get(`${link.pathname}${link.search}`) - expect(res.status).toBe(302) - await waitForExpect(() => { - const captured = mockInternalFetch.mock.calls.map(([, options]) => - parseJSON(options!.body as string) - ) - expect(captured).toEqual([ - expect.objectContaining({ - event: '$workflows_email_link_clicked', - distinct_id: 'recipient', - properties: expect.objectContaining({ $workflow_run_id: invocationId }), - }), - ]) - }) + const res = await supertest(app).get(`${link.pathname}${link.search}`) + expect(res.status).toBe(302) + + await waitForExpect(() => { + const captured = mockInternalFetch.mock.calls.map(([, options]) => + parseJSON(options!.body as string) + ) + expect(captured).toEqual([ + expect.objectContaining({ + event: '$workflows_email_link_clicked', + distinct_id: 'recipient', + properties: expect.objectContaining({ $workflow_run_id: invocationId }), + }), + ]) + }) + } finally { + captureSpy.mockRestore() + } }) it('should return 404 for an unsigned link after the unsigned link sunset', async () => {