From a72764b81565ed1827e5c89ed90f227272817151 Mon Sep 17 00:00:00 2001 From: Xinyi Ye Date: Fri, 31 Jul 2026 14:35:28 -0700 Subject: [PATCH] fix(analytics-core): drop events with an empty event type before upload Events with a blank event type are rejected by the event server with a 400, which also forces the rest of their upload batch to be retried. 99.6% of dropped Browser SDK events have a blank event name. Validate client-side in AmplitudeCore.process() instead: events whose event_type is empty, whitespace-only, or not a string are dropped with a warning log before reaching the timeline or any destination. Mirrors the same check added to the Kotlin SDK in amplitude/Amplitude-Kotlin#444. Co-Authored-By: Claude Opus 5 (1M context) --- packages/analytics-core/src/core-client.ts | 10 +++++- packages/analytics-core/src/types/messages.ts | 1 + .../analytics-core/test/core-client.test.ts | 34 ++++++++++++++++++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/analytics-core/src/core-client.ts b/packages/analytics-core/src/core-client.ts index 1ab545862c..e111b7b88a 100644 --- a/packages/analytics-core/src/core-client.ts +++ b/packages/analytics-core/src/core-client.ts @@ -11,7 +11,7 @@ import { } from './types/event/event'; import { IIdentify, OrderedIdentifyOperations } from './identify'; import { IRevenue } from './revenue'; -import { CLIENT_NOT_INITIALIZED, OPT_OUT_MESSAGE } from './types/messages'; +import { CLIENT_NOT_INITIALIZED, EMPTY_EVENT_TYPE_MESSAGE, OPT_OUT_MESSAGE } from './types/messages'; import { Timeline } from './timeline'; import { createGroupEvent, @@ -239,6 +239,14 @@ export class AmplitudeCore implements CoreClient, PluginHost { return buildResult(event, 0, OPT_OUT_MESSAGE); } + // The event server 400s a blank event type, which also forces the rest of its + // upload batch to be retried, so drop it before it ever reaches a destination. + const eventType: unknown = event.event_type; + if (typeof eventType !== 'string' || eventType.trim().length === 0) { + this.config.loggerProvider.warn(EMPTY_EVENT_TYPE_MESSAGE); + return buildResult(event, 0, EMPTY_EVENT_TYPE_MESSAGE); + } + if (event.event_type === SpecialEventType.IDENTIFY) { // Do not update this.userProperties here. // It is only set synchronously in identify() or setIdentity() diff --git a/packages/analytics-core/src/types/messages.ts b/packages/analytics-core/src/types/messages.ts index e4686d76e6..4958acfc22 100644 --- a/packages/analytics-core/src/types/messages.ts +++ b/packages/analytics-core/src/types/messages.ts @@ -2,6 +2,7 @@ export const SUCCESS_MESSAGE = 'Event tracked successfully'; export const UNEXPECTED_ERROR_MESSAGE = 'Unexpected error occurred'; export const MAX_RETRIES_EXCEEDED_MESSAGE = 'Event rejected due to exceeded retry count'; export const OPT_OUT_MESSAGE = 'Event skipped due to optOut config'; +export const EMPTY_EVENT_TYPE_MESSAGE = 'Event skipped due to empty event type'; export const MISSING_API_KEY_MESSAGE = 'Event rejected due to missing API key'; export const INVALID_API_KEY = 'Invalid API key'; export const CLIENT_NOT_INITIALIZED = 'Client not initialized'; diff --git a/packages/analytics-core/test/core-client.test.ts b/packages/analytics-core/test/core-client.test.ts index 151805eac3..1fc6d6efdd 100644 --- a/packages/analytics-core/test/core-client.test.ts +++ b/packages/analytics-core/test/core-client.test.ts @@ -4,7 +4,7 @@ import { Event, IdentifyEvent, SpecialEventType, UserProperties } from '../src/t import { Plugin, EnrichmentPlugin } from '../src/types/plugin'; import { Status } from '../src/types/status'; import { AmplitudeCore, Identify, Revenue } from '../src/index'; -import { CLIENT_NOT_INITIALIZED, OPT_OUT_MESSAGE } from '../src/types/messages'; +import { CLIENT_NOT_INITIALIZED, EMPTY_EVENT_TYPE_MESSAGE, OPT_OUT_MESSAGE } from '../src/types/messages'; import { useDefaultConfig } from './helpers/default'; import { IdentifyOperation } from '../src/identify'; import { UNSET_VALUE } from '../src/types/constants'; @@ -483,6 +483,38 @@ describe('core-client', () => { expect(onIdentityChanged).toHaveBeenCalledTimes(1); expect(onIdentityChanged).toHaveBeenCalledWith({ userProperties: undefined }); }); + + test.each([ + ['an empty', ''], + ['a whitespace-only', ' '], + ['a missing', undefined], + ])('should drop an event with %s event type', async (_description, eventType) => { + const client = new AmplitudeCore(); + const loggerProvider = { ...mockLoggerProvider, warn: jest.fn() }; + client.config = { ...mockConfig, loggerProvider } as BrowserConfig; + const push = jest.spyOn(client.timeline, 'push'); + + const event = { event_type: eventType } as Event; + const result = await client.process(event); + + expect(result).toEqual({ event, code: 0, message: EMPTY_EVENT_TYPE_MESSAGE }); + expect(push).toHaveBeenCalledTimes(0); + expect(loggerProvider.warn).toHaveBeenCalledTimes(1); + expect(loggerProvider.warn).toHaveBeenCalledWith(EMPTY_EVENT_TYPE_MESSAGE); + }); + + test('should not drop an event with a valid event type', async () => { + const client = new AmplitudeCore(); + const loggerProvider = { ...mockLoggerProvider, warn: jest.fn() }; + client.config = { ...mockConfig, loggerProvider } as BrowserConfig; + const push = jest.spyOn(client.timeline, 'push').mockReturnValueOnce(Promise.resolve(success)); + + const result = await client.process({ event_type: 'event_type' }); + + expect(result).toBe(success); + expect(push).toHaveBeenCalledTimes(1); + expect(loggerProvider.warn).toHaveBeenCalledTimes(0); + }); }); describe('setOptOut', () => {