From 1f934ee862a90aeacc452f09de8b18c92c9b0287 Mon Sep 17 00:00:00 2001 From: Sunita Prajapati Date: Tue, 8 Sep 2026 11:44:59 +0530 Subject: [PATCH 1/2] fix(core): don't log full event payloads or deep-link tokens at info level --- .../__tests__/internal/eventLogging.test.ts | 141 ++++++++++++++++++ packages/core/src/analytics.ts | 76 ++++++++-- packages/core/src/types.ts | 7 + 3 files changed, 213 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/__tests__/internal/eventLogging.test.ts diff --git a/packages/core/src/__tests__/internal/eventLogging.test.ts b/packages/core/src/__tests__/internal/eventLogging.test.ts new file mode 100644 index 000000000..c0651ad1f --- /dev/null +++ b/packages/core/src/__tests__/internal/eventLogging.test.ts @@ -0,0 +1,141 @@ +import { SegmentClient } from '../../analytics'; +import { + createMockStoreGetter, + getMockLogger, + MockSegmentStore, +} from '../../test-helpers'; +import { EventType } from '../../types'; + +jest.mock('uuid'); + +jest + .spyOn(Date.prototype, 'toISOString') + .mockReturnValue('2010-01-01T00:00:00.000Z'); + +describe('event logging', () => { + const store = new MockSegmentStore({ + userInfo: { + userId: 'current-user-id', + anonymousId: 'very-anonymous', + }, + }); + + const baseConfig = { + writeKey: 'mock-write-key', + flushInterval: 0, + }; + + beforeEach(() => { + jest.clearAllMocks(); + store.reset(); + }); + + it('logs only non-sensitive metadata by default, not the full payload', async () => { + const logger = getMockLogger(); + const client = new SegmentClient({ + config: baseConfig, + logger, + store, + }); + + await client.identify('user-with-secrets', { email: 'secret@example.com' }); + + expect(logger.info).toHaveBeenCalledTimes(1); + const [message, metadata] = (logger.info as jest.Mock).mock.calls[0] as [ + string, + Record + ]; + expect(message).toBe('IDENTIFY event saved'); + expect(metadata).toEqual({ + type: EventType.IdentifyEvent, + messageId: expect.any(String), + }); + expect(JSON.stringify(metadata)).not.toContain('secret@example.com'); + }); + + it('does not warn about debugPayloads when it is not enabled', () => { + const logger = getMockLogger(); + // eslint-disable-next-line no-new + new SegmentClient({ config: baseConfig, logger, store }); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('warns once at startup when debugPayloads is enabled', () => { + const logger = getMockLogger(); + // eslint-disable-next-line no-new + new SegmentClient({ + config: { ...baseConfig, debugPayloads: true }, + logger, + store, + }); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('debugPayloads is enabled') + ); + }); + + it('also logs the full payload when debugPayloads is enabled', async () => { + const logger = getMockLogger(); + const client = new SegmentClient({ + config: { ...baseConfig, debugPayloads: true }, + logger, + store, + }); + + await client.track('Some Event', { id: 1 }); + + expect(logger.info).toHaveBeenCalledTimes(2); + expect(logger.info).toHaveBeenNthCalledWith( + 1, + 'TRACK event saved', + expect.objectContaining({ + type: EventType.TrackEvent, + name: 'Some Event', + }) + ); + expect(logger.info).toHaveBeenNthCalledWith( + 2, + 'TRACK event payload', + expect.objectContaining({ + event: 'Some Event', + properties: { id: 1 }, + }) + ); + }); + + it('redacts the deep-link URL query string even when debugPayloads is enabled', async () => { + const logger = getMockLogger(); + const deepLinkData = { + url: 'myapp://open?token=super-secret&other=1', + referring_application: 'Safari', + }; + jest + .spyOn(store.deepLinkData, 'get') + .mockImplementation(createMockStoreGetter(() => deepLinkData)); + + const client = new SegmentClient({ + config: { + ...baseConfig, + trackDeepLinks: true, + trackAppLifecycleEvents: false, + debugPayloads: true, + }, + logger, + store, + }); + + await client.init(); + + const payloadCall = (logger.info as jest.Mock).mock.calls.find( + ([message]) => message === 'TRACK (Deep Link Opened) event payload' + ) as [string, { properties: { url: string } }]; + + expect(payloadCall).toBeDefined(); + expect(payloadCall[1].properties.url).toBe('myapp://open'); + expect(JSON.stringify(payloadCall)).not.toContain('super-secret'); + + client.cleanup(); + }); +}); diff --git a/packages/core/src/analytics.ts b/packages/core/src/analytics.ts index 79a886c3a..c9f96ed09 100644 --- a/packages/core/src/analytics.ts +++ b/packages/core/src/analytics.ts @@ -80,6 +80,23 @@ import { WaitingPlugin } from './plugin'; type OnPluginAddedCallback = (plugin: Plugin) => void; +// Deep link URLs can carry secrets/tokens in their query string, so strip it before +// the full event is logged (only reachable when `debugPayloads` is enabled). +const redactDeepLinkUrl = (event: SegmentEvent): SegmentEvent => { + if ( + event.type === EventType.TrackEvent && + event.event === 'Deep Link Opened' && + typeof event.properties?.url === 'string' + ) { + const [urlWithoutQuery] = event.properties.url.split('?'); + return { + ...event, + properties: { ...event.properties, url: urlWithoutQuery }, + }; + } + return event; +}; + export class SegmentClient { // the config parameters for the client - a merge of user provided and default options private config: Config; @@ -296,6 +313,12 @@ export class SegmentClient { // set up tracking for lifecycle events this.setupLifecycleEvents(); + + if (this.config.debugPayloads === true) { + this.logger.warn( + 'debugPayloads is enabled: full event payloads (which may include PII and deep-link tokens) will be logged at info level. Do not enable this in production.' + ); + } } // Watch for isReady so that we can handle any pending events @@ -600,7 +623,38 @@ export class SegmentClient { }); void this.process(event); - this.logger.info('TRACK (Deep Link Opened) event saved', event); + this.logEventSaved('TRACK (Deep Link Opened)', event); + } + } + + /** + * Builds the safe-to-log metadata for an event: type, messageId and the event/screen + * name. Deliberately excludes userId, traits, properties and context, which may hold PII. + */ + private getEventLogMetadata(event: SegmentEvent): Record { + const name = + event.type === EventType.TrackEvent + ? event.event + : event.type === EventType.ScreenEvent + ? event.name + : undefined; + return { + type: event.type, + messageId: event.messageId, + ...(name !== undefined ? { name } : {}), + }; + } + + /** + * Logs that an event was saved. Only non-sensitive metadata is logged by default; + * the full payload (which may include PII, or a deep-link URL with query tokens) + * is only logged when `debugPayloads` is explicitly enabled in the config. + */ + private logEventSaved(label: string, event: SegmentEvent) { + this.logger.info(`${label} event saved`, this.getEventLogMetadata(event)); + + if (this.getConfig().debugPayloads === true) { + this.logger.info(`${label} event payload`, redactDeepLinkUrl(event)); } } @@ -677,8 +731,8 @@ export class SegmentClient { properties: options, }); - await this.process(event, enrichment); - this.logger.info('SCREEN event saved', event); + const processedEvent = await this.process(event, enrichment); + this.logEventSaved('SCREEN', processedEvent ?? event); } async track( @@ -691,8 +745,8 @@ export class SegmentClient { properties: options, }); - await this.process(event, enrichment); - this.logger.info('TRACK event saved', event); + const processedEvent = await this.process(event, enrichment); + this.logEventSaved('TRACK', processedEvent ?? event); } async identify( @@ -705,8 +759,8 @@ export class SegmentClient { userTraits: userTraits, }); - await this.process(event, enrichment); - this.logger.info('IDENTIFY event saved', event); + const processedEvent = await this.process(event, enrichment); + this.logEventSaved('IDENTIFY', processedEvent ?? event); } async group( @@ -719,8 +773,8 @@ export class SegmentClient { groupTraits, }); - await this.process(event, enrichment); - this.logger.info('GROUP event saved', event); + const processedEvent = await this.process(event, enrichment); + this.logEventSaved('GROUP', processedEvent ?? event); } async alias(newUserId: string, enrichment?: EnrichmentClosure) { @@ -734,8 +788,8 @@ export class SegmentClient { newUserId, }); - await this.process(event, enrichment); - this.logger.info('ALIAS event saved', event); + const processedEvent = await this.process(event, enrichment); + this.logEventSaved('ALIAS', processedEvent ?? event); } /** diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4cbd6b5ba..ccf9daa4f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -137,6 +137,13 @@ export type Config = { writeKey: string; debug?: boolean; logger?: DeactivableLoggerType; + /** + * By default, the SDK only logs non-sensitive event metadata (type, messageId, event/name) + * at info level. Set this to log the full event payload instead, which can include PII + * (userId, traits, properties) and, for deep link events, the full URL with any query + * string tokens. Only enable this for local debugging - never in production. + */ + debugPayloads?: boolean; // Legacy, for compat only flushAt?: number; flushInterval?: number; From 4489ba5d8b70d9d9cf2141dc7aa871266a3d3331 Mon Sep 17 00:00:00 2001 From: Sunita Prajapati Date: Wed, 9 Sep 2026 14:07:43 +0530 Subject: [PATCH 2/2] fix(core): address review feedback on event-logging PII redaction didiergarcia flagged four issues on #1326: - redactDeepLinkUrl only stripped the query string; OAuth callback tokens in the URL fragment (myapp://callback#access_token=...) survived redaction. Split on /[?#]/ instead of '?'. - The deep-link track call logged the pre-process event because `void this.process(event)` discarded its result, so messageId was always undefined. Await the result via .then() (keeping the call site fire-and-forget) and log the processed event. - `processedEvent ?? event` collapsed "saved", "disabled", and "dropped by a before/consent plugin" into one misleading "event saved" log. Added logEventResult() to log "