UNPKG

@segment/analytics-react-native

Version:

The hassle-free way to add Segment analytics to your React-Native app.

500 lines (444 loc) 15.5 kB
import { DestinationPlugin } from '../plugin'; import { BackoffConfig, Config, HttpConfig, PluginType, RateLimitConfig, SegmentAPIIntegration, SegmentAPISettings, SegmentEvent, UpdateType, } from '../types'; import { chunk, createPromise, getURL } from '../util'; import { uploadEvents } from '../api'; import type { SegmentClient } from '../analytics'; 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 type { RetryResult } from '../backoff'; 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'; type BatchResult = { batch: SegmentEvent[]; messageIds: string[]; // 'retry_after' = a retryable response that carried a Retry-After header // (429 or any other retryable code), so we wait the server-directed time // instead of computing exponential backoff. status: | 'success' | 'retry_after' | 'transient' | 'permanent' | 'network_error'; statusCode?: number; retryAfterSeconds?: number; }; type ErrorAggregation = { successfulMessageIds: string[]; serverDirectedResults: BatchResult[]; hasTransientError: boolean; permanentErrorMessageIds: string[]; retryableMessageIds: string[]; }; export class SegmentDestination extends DestinationPlugin { type = PluginType.destination; key = SEGMENT_DESTINATION_KEY; private apiHost?: string; private httpConfig?: HttpConfig; private settingsResolve: () => void; private settingsPromise: Promise<void>; private retryManager?: RetryManager; constructor() { super(); // We don't timeout this promise. We strictly need the response from Segment before sending things const { promise, resolve } = createPromise<void>(); this.settingsPromise = promise; this.settingsResolve = resolve; } private getRateLimitConfig(): RateLimitConfig | undefined { return this.httpConfig?.rateLimitConfig; } private getBackoffConfig(): BackoffConfig | undefined { return this.httpConfig?.backoffConfig; } private classifyBatchResult( res: Response, batch: SegmentEvent[], messageIds: string[], retryAfterSeconds?: number ): BatchResult { 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, }; } } private async uploadBatch(batch: SegmentEvent[]): Promise<BatchResult> { const config = this.analytics?.getConfig() ?? defaultConfig; const messageIds = batch .map((e) => e.messageId) .filter((id): id is string => 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 as SegmentEvent[], 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' }; } } private reportDroppedEvents( count: number, reason: 'max_age_exceeded' | 'permanent_error' | 'retry_limit_exceeded', logMessage: string ): void { this.analytics?.reportInternalError( new SegmentError(ErrorType.EventsDropped, logMessage, undefined, { droppedCount: count, reason, }) ); this.analytics?.logger.error(logMessage); } private aggregateErrors(results: BatchResult[]): ErrorAggregation { const aggregation: ErrorAggregation = { 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. */ private async pruneExpiredEvents( events: SegmentEvent[] ): Promise<SegmentEvent[]> { const maxAge = this.httpConfig?.backoffConfig?.maxTotalBackoffDuration ?? 0; if (maxAge <= 0) { return events; } const now = Date.now(); const maxAgeMs = maxAge * 1000; const expiredMessageIds: string[] = []; const freshEvents: SegmentEvent[] = []; 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). */ private async updateRetryState( aggregation: ErrorAggregation ): Promise<boolean> { if (!this.retryManager) { return false; } const hasServerDirectedWait = aggregation.serverDirectedResults.length > 0; let result: RetryResult | undefined; 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'; } private async processUploadResults( events: SegmentEvent[], aggregation: ErrorAggregation, limitExceeded: boolean, config: Config ): Promise<void> { 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})` ); } } private sendEvents = async (events: SegmentEvent[]): Promise<void> => { 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: SegmentEvent[][] = chunk( freshEvents, config.maxBatchSize ?? MAX_EVENTS_PER_BATCH, MAX_PAYLOAD_SIZE_IN_KB ); const results: BatchResult[] = 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 ); }; private readonly queuePlugin = new QueueFlushingPlugin(this.sendEvents); private getEndpoint(): string { 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: SegmentClient): void { 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: SegmentAPISettings, _type: UpdateType): void { const segmentSettings = settings.integrations[ this.key ] as SegmentAPIIntegration; 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] as | Record<string, unknown> | undefined; let httpConfig: HttpConfig | undefined; if (rawIntegration?.httpConfig !== undefined) { httpConfig = extractHttpConfig( rawIntegration.httpConfig as 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: SegmentEvent): Promise<SegmentEvent | undefined> { // 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(); } }