@segment/analytics-react-native
Version:
The hassle-free way to add Segment analytics to your React-Native app.
329 lines (321 loc) • 12.9 kB
JavaScript
import { DestinationPlugin } from '../plugin';
import { PluginType } from '../types';
import { chunk, createPromise, getURL } from '../util';
import { uploadEvents } from '../api';
import { DestinationMetadataEnrichment } from './DestinationMetadataEnrichment';
import { QueueFlushingPlugin } from './QueueFlushingPlugin';
import { defaultApiHost, defaultConfig } from '../constants';
import { SegmentError, ErrorType, translateHTTPError, classifyError, parseRetryAfter } from '../errors';
import { RetryManager } from '../backoff/RetryManager';
import { extractHttpConfig } from '../config-validation';
const MAX_EVENTS_PER_BATCH = 100;
const MAX_PAYLOAD_SIZE_IN_KB = 500;
export const SEGMENT_DESTINATION_KEY = 'Segment.io';
export class SegmentDestination extends DestinationPlugin {
type = PluginType.destination;
key = SEGMENT_DESTINATION_KEY;
constructor() {
super();
// We don't timeout this promise. We strictly need the response from Segment before sending things
const {
promise,
resolve
} = createPromise();
this.settingsPromise = promise;
this.settingsResolve = resolve;
}
getRateLimitConfig() {
return this.httpConfig?.rateLimitConfig;
}
getBackoffConfig() {
return this.httpConfig?.backoffConfig;
}
classifyBatchResult(res, batch, messageIds, retryAfterSeconds) {
if (res.ok) {
return {
batch,
messageIds,
status: 'success',
statusCode: res.status
};
}
const classification = classifyError(res.status, {
default4xxBehavior: this.getBackoffConfig()?.default4xxBehavior,
default5xxBehavior: this.getBackoffConfig()?.default5xxBehavior,
statusCodeOverrides: this.getBackoffConfig()?.statusCodeOverrides,
rateLimitEnabled: this.getRateLimitConfig()?.enabled,
backoffEnabled: this.getBackoffConfig()?.enabled
});
switch (classification.errorType) {
case 'rate_limit':
// 429: always a server-directed wait. Default to 60s when the header
// is missing/invalid, preserving prior behavior.
return {
batch,
messageIds,
status: 'retry_after',
statusCode: res.status,
retryAfterSeconds: retryAfterSeconds ?? 60
};
case 'transient':
// Any other retryable code (529, 503, 408, …): if the server sent a
// valid Retry-After, honor it as a server-directed wait. Otherwise use
// exponential backoff (unchanged behavior).
if (retryAfterSeconds !== undefined) {
return {
batch,
messageIds,
status: 'retry_after',
statusCode: res.status,
retryAfterSeconds
};
}
return {
batch,
messageIds,
status: 'transient',
statusCode: res.status
};
default:
// Permanent: drop. Retry-After is ignored on non-retryable codes.
return {
batch,
messageIds,
status: 'permanent',
statusCode: res.status
};
}
}
async uploadBatch(batch) {
const config = this.analytics?.getConfig() ?? defaultConfig;
const messageIds = batch.map(e => e.messageId).filter(id => id !== undefined && id !== '');
const retryCount = this.retryManager ? await this.retryManager.getRetryCount() : 0;
const cleanedBatch = batch.map(({
_queuedAt,
...event
}) => event);
try {
const res = await uploadEvents({
writeKey: config.writeKey,
url: this.getEndpoint(),
events: cleanedBatch,
retryCount
});
// Parse Retry-After on any error response (not just 429). The header —
// regardless of which retryable status code carries it — is the
// authoritative signal for how long to wait. classifyBatchResult decides
// whether to honor it (retryable codes) or ignore it (permanent codes).
const retryAfterSeconds = res.ok ? undefined : parseRetryAfter(res.headers.get('Retry-After'), this.getRateLimitConfig()?.maxRetryInterval);
return this.classifyBatchResult(res, batch, messageIds, retryAfterSeconds);
} catch (e) {
this.analytics?.reportInternalError(translateHTTPError(e));
return {
batch,
messageIds,
status: 'network_error'
};
}
}
reportDroppedEvents(count, reason, logMessage) {
this.analytics?.reportInternalError(new SegmentError(ErrorType.EventsDropped, logMessage, undefined, {
droppedCount: count,
reason
}));
this.analytics?.logger.error(logMessage);
}
aggregateErrors(results) {
const aggregation = {
successfulMessageIds: [],
serverDirectedResults: [],
hasTransientError: false,
permanentErrorMessageIds: [],
retryableMessageIds: []
};
for (const result of results) {
switch (result.status) {
case 'success':
aggregation.successfulMessageIds.push(...result.messageIds);
break;
case 'retry_after':
aggregation.serverDirectedResults.push(result);
aggregation.retryableMessageIds.push(...result.messageIds);
break;
case 'transient':
case 'network_error':
aggregation.hasTransientError = true;
aggregation.retryableMessageIds.push(...result.messageIds);
break;
case 'permanent':
aggregation.permanentErrorMessageIds.push(...result.messageIds);
break;
}
}
return aggregation;
}
/**
* Drop events whose _queuedAt exceeds maxTotalBackoffDuration.
* Returns the remaining fresh events.
*/
async pruneExpiredEvents(events) {
const maxAge = this.httpConfig?.backoffConfig?.maxTotalBackoffDuration ?? 0;
if (maxAge <= 0) {
return events;
}
const now = Date.now();
const maxAgeMs = maxAge * 1000;
const expiredMessageIds = [];
const freshEvents = [];
for (const event of events) {
if (event._queuedAt !== undefined && now - event._queuedAt > maxAgeMs) {
if (event.messageId !== undefined && event.messageId !== '') {
expiredMessageIds.push(event.messageId);
}
} else {
freshEvents.push(event);
}
}
if (expiredMessageIds.length > 0) {
await this.queuePlugin.dequeueByMessageIds(expiredMessageIds);
this.reportDroppedEvents(expiredMessageIds.length, 'max_age_exceeded', `Dropped ${expiredMessageIds.length} events exceeding max age (${maxAge}s)`);
this.analytics?.logger.warn(`Pruned ${expiredMessageIds.length} events older than ${maxAge}s`);
}
return freshEvents;
}
/**
* Update retry state based on aggregated batch results.
* 429 takes precedence over transient errors.
* Returns true if retry limits were exceeded (caller should drop events).
*/
async updateRetryState(aggregation) {
if (!this.retryManager) {
return false;
}
const hasServerDirectedWait = aggregation.serverDirectedResults.length > 0;
let result;
if (hasServerDirectedWait) {
for (const r of aggregation.serverDirectedResults) {
result = await this.retryManager.handleRetryAfter(r.retryAfterSeconds ?? 60);
}
} else if (aggregation.hasTransientError) {
result = await this.retryManager.handleTransientError();
} else if (aggregation.successfulMessageIds.length > 0) {
await this.retryManager.reset();
}
return result === 'limit_exceeded';
}
async processUploadResults(events, aggregation, limitExceeded, config) {
if (aggregation.successfulMessageIds.length > 0) {
await this.queuePlugin.dequeueByMessageIds(aggregation.successfulMessageIds);
if (config.debug === true) {
this.analytics?.logger.info(`Sent ${aggregation.successfulMessageIds.length} events`);
}
}
if (aggregation.permanentErrorMessageIds.length > 0) {
await this.queuePlugin.dequeueByMessageIds(aggregation.permanentErrorMessageIds);
this.reportDroppedEvents(aggregation.permanentErrorMessageIds.length, 'permanent_error', `Dropped ${aggregation.permanentErrorMessageIds.length} events due to permanent errors`);
}
if (limitExceeded && aggregation.retryableMessageIds.length > 0) {
await this.queuePlugin.dequeueByMessageIds(aggregation.retryableMessageIds);
this.reportDroppedEvents(aggregation.retryableMessageIds.length, 'retry_limit_exceeded', `Dropped ${aggregation.retryableMessageIds.length} events due to retry limit exceeded`);
}
const failedCount = events.length - aggregation.successfulMessageIds.length - aggregation.permanentErrorMessageIds.length;
if (failedCount > 0) {
const hasServerDirectedWait = aggregation.serverDirectedResults.length > 0;
this.analytics?.logger.warn(`${failedCount} events will retry (retry-after: ${hasServerDirectedWait}, transient: ${aggregation.hasTransientError})`);
}
}
sendEvents = async events => {
if (events.length === 0) {
await this.retryManager?.reset();
return;
}
// We're not sending events until Segment has loaded all settings
await this.settingsPromise;
const config = this.analytics?.getConfig() ?? defaultConfig;
const freshEvents = await this.pruneExpiredEvents(events);
if (freshEvents.length === 0) {
await this.retryManager?.reset();
return;
}
if (this.retryManager && !(await this.retryManager.canRetry())) {
this.analytics?.logger.info('Upload blocked by retry manager');
return;
}
const batches = chunk(freshEvents, config.maxBatchSize ?? MAX_EVENTS_PER_BATCH, MAX_PAYLOAD_SIZE_IN_KB);
const results = await Promise.all(batches.map(batch => this.uploadBatch(batch)));
const aggregation = this.aggregateErrors(results);
const limitExceeded = await this.updateRetryState(aggregation);
await this.processUploadResults(freshEvents, aggregation, limitExceeded, config);
};
queuePlugin = new QueueFlushingPlugin(this.sendEvents);
getEndpoint() {
const config = this.analytics?.getConfig();
const hasProxy = !!(config?.proxy ?? '');
const useSegmentEndpoints = Boolean(config?.useSegmentEndpoints);
let baseURL = '';
let endpoint = '';
if (hasProxy) {
//baseURL is always config?.proxy if hasProxy
baseURL = config?.proxy ?? '';
if (useSegmentEndpoints) {
const isProxyEndsWithSlash = baseURL.endsWith('/');
endpoint = isProxyEndsWithSlash ? 'b' : '/b';
}
} else {
baseURL = this.apiHost ?? defaultApiHost;
}
try {
return getURL(baseURL, endpoint);
} catch (error) {
console.error('Error in getEndpoint:', `fallback to ${defaultApiHost}`);
return defaultApiHost;
}
}
configure(analytics) {
super.configure(analytics);
const config = analytics.getConfig();
// If the client has a proxy we don't need to await for settings apiHost, we can send events directly
// Important! If new settings are required in the future you probably want to change this!
if (config.proxy !== undefined) {
this.settingsResolve();
}
// Enrich events with the Destination metadata
this.add(new DestinationMetadataEnrichment(SEGMENT_DESTINATION_KEY));
this.add(this.queuePlugin);
}
// We block sending stuff to segment until we get the settings
update(settings, _type) {
const segmentSettings = settings.integrations[this.key];
if (segmentSettings?.apiHost !== undefined && segmentSettings?.apiHost !== null) {
//assign the api host from segment settings (domain/v1)
this.apiHost = `https://${segmentSettings.apiHost}/b`;
}
// Read httpConfig: prefer integration-level settings from CDN, fall back to
// top-level CDN config merged with client config (via analytics.getHttpConfig()).
const rawIntegration = settings.integrations[this.key];
let httpConfig;
if (rawIntegration?.httpConfig !== undefined) {
httpConfig = extractHttpConfig(rawIntegration.httpConfig, this.analytics?.logger);
}
if (!httpConfig) {
httpConfig = this.analytics?.getHttpConfig();
}
if (httpConfig) {
this.httpConfig = httpConfig;
if (!this.retryManager && (httpConfig.rateLimitConfig || httpConfig.backoffConfig)) {
const config = this.analytics?.getConfig();
this.retryManager = new RetryManager(config?.writeKey ?? '', config?.storePersistor, httpConfig.rateLimitConfig, httpConfig.backoffConfig, this.analytics?.logger);
}
}
this.settingsResolve();
}
execute(event) {
// Execute the internal timeline here, the queue plugin will pick up the event and add it to the queue automatically
return super.execute(event);
}
async flush() {
// Wait until the queue is done restoring before flushing
return this.queuePlugin.flush();
}
}
//# sourceMappingURL=SegmentDestination.js.map