Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 202 additions & 0 deletions packages/core/src/__tests__/internal/eventLogging.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
];
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',
})
);
});
});
100 changes: 88 additions & 12 deletions packages/core/src/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown> {
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);
}

/**
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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) {
Expand All @@ -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);
}

/**
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading