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..60e0c63df --- /dev/null +++ b/packages/core/src/__tests__/internal/eventLogging.test.ts @@ -0,0 +1,202 @@ +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(); + }); + + it('redacts the deep-link URL fragment (e.g. OAuth callback tokens) too', async () => { + const logger = getMockLogger(); + const deepLinkData = { + url: 'myapp://callback#access_token=super-secret', + 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://callback'); + expect(JSON.stringify(payloadCall)).not.toContain('super-secret'); + + client.cleanup(); + }); + + it('logs an event as dropped, not saved, when analytics is disabled', async () => { + const logger = getMockLogger(); + const disabledStore = new MockSegmentStore({ + enabled: false, + userInfo: { + userId: 'current-user-id', + anonymousId: 'very-anonymous', + }, + }); + const client = new SegmentClient({ + config: baseConfig, + logger, + store: disabledStore, + }); + + await client.track('Some Event', { id: 1 }); + + expect(logger.info).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith( + 'TRACK event dropped', + expect.objectContaining({ + type: EventType.TrackEvent, + name: 'Some Event', + }) + ); + }); +}); diff --git a/packages/core/src/analytics.ts b/packages/core/src/analytics.ts index 79a886c3a..7dff4053b 100644 --- a/packages/core/src/analytics.ts +++ b/packages/core/src/analytics.ts @@ -80,6 +80,24 @@ import { WaitingPlugin } from './plugin'; type OnPluginAddedCallback = (plugin: Plugin) => void; +// Deep link URLs can carry secrets/tokens in their query string or fragment (mobile OAuth +// callbacks use both), so strip both 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 [urlWithoutQueryOrFragment] = event.properties.url.split(/[?#]/); + return { + ...event, + properties: { ...event.properties, url: urlWithoutQueryOrFragment }, + }; + } + return event; +}; + export class SegmentClient { // the config parameters for the client - a merge of user provided and default options private config: Config; @@ -296,6 +314,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 @@ -599,9 +623,61 @@ export class SegmentClient { }, }); - void this.process(event); - this.logger.info('TRACK (Deep Link Opened) event saved', event); + void this.process(event).then((processedEvent) => { + this.logEventResult('TRACK (Deep Link Opened)', event, processedEvent); + }); + } + } + + /** + * 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)); + } + } + + /** + * Logs the outcome of processing an event. `processedEvent` is `undefined` when + * analytics is disabled or a before/enrichment plugin (e.g. consent gating) dropped + * the event, in which case that's logged as "dropped" rather than misreported as saved. + */ + private logEventResult( + label: string, + event: SegmentEvent, + processedEvent: SegmentEvent | undefined + ) { + if (processedEvent === undefined) { + this.logger.info( + `${label} event dropped`, + this.getEventLogMetadata(event) + ); + return; } + this.logEventSaved(label, processedEvent); } /** @@ -677,8 +753,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.logEventResult('SCREEN', event, processedEvent); } async track( @@ -691,8 +767,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.logEventResult('TRACK', event, processedEvent); } async identify( @@ -705,8 +781,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.logEventResult('IDENTIFY', event, processedEvent); } async group( @@ -719,8 +795,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.logEventResult('GROUP', event, processedEvent); } async alias(newUserId: string, enrichment?: EnrichmentClosure) { @@ -734,8 +810,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.logEventResult('ALIAS', event, processedEvent); } /** diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4cbd6b5ba..c38677576 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 also log the full event payload, 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;