UNPKG

react-native-avo-inspector

Version:

[![npm version](https://badge.fury.io/js/react-native-avo-inspector.svg)](https://badge.fury.io/js/react-native-avo-inspector)

471 lines (470 loc) 21.4 kB
import { AvoInspectorEnv } from "./AvoInspectorEnv"; import { AvoSchemaParser } from "./AvoSchemaParser"; import { AvoBatcher } from "./AvoBatcher"; import { AvoNetworkCallsHandler } from "./AvoNetworkCallsHandler"; import { AvoStorage } from "./AvoStorage"; import { AvoDeduplicator } from "./AvoDeduplicator"; import { AvoStreamId } from "./AvoStreamId"; import { isValueEmpty } from "./utils"; import { LIB_VERSION } from "./version"; const libVersion = LIB_VERSION; export class AvoInspector { static get batchSize() { return this._batchSize; } static set batchSize(newSize) { if (newSize < 1) { this._batchSize = 1; } else { this._batchSize = newSize; } } static get batchFlushSeconds() { return this._batchFlushSeconds; } static get shouldLog() { return this._shouldLog; } static set shouldLog(enable) { this._shouldLog = enable; } static get networkTimeout() { return this._networkTimeout; } static set networkTimeout(timeout) { this._networkTimeout = timeout; } constructor(options) { this.currentBranchId = null; // the constructor does aggressive null/undefined checking because same code paths will be accessible from JS if (isValueEmpty(options.env)) { this.environment = AvoInspectorEnv.Dev; console.warn("[Avo Inspector] No environment provided. Defaulting to dev."); } else if (!Object.values(AvoInspectorEnv).includes(options.env)) { this.environment = AvoInspectorEnv.Dev; console.warn("[Avo Inspector] Unsupported environment provided. Defaulting to dev. Supported environments - Dev, Staging, Prod."); } else { this.environment = options.env; } if (isValueEmpty(options.apiKey)) { throw new Error("[Avo Inspector] No API key provided. Inspector can't operate without API key."); } else { this.apiKey = options.apiKey; } if (isValueEmpty(options.version)) { throw new Error("[Avo Inspector] No version provided. Many features of Inspector rely on versioning. Please provide comparable string version, i.e. integer or semantic."); } else { this.version = options.version; } if (this.environment === AvoInspectorEnv.Dev) { AvoInspector._batchSize = 1; AvoInspector._shouldLog = true; } else { AvoInspector._batchSize = 30; AvoInspector._batchFlushSeconds = 30; AvoInspector._shouldLog = false; } AvoInspector.avoStorage = new AvoStorage(AvoInspector._shouldLog, options.suffix != null ? options.suffix : ""); this.publicEncryptionKey = options.publicEncryptionKey; this.avoNetworkCallsHandler = new AvoNetworkCallsHandler(this.apiKey, this.environment.toString(), options.appName || "", this.version, libVersion, this.publicEncryptionKey); this.avoBatcher = new AvoBatcher(this.avoNetworkCallsHandler); this.avoDeduplicator = new AvoDeduplicator(); this.streamId = AvoStreamId.streamId; // Enable event spec fetching if streamId is present (note: "unknown" is truthy and will trigger initEventSpecModules; see streamId edge case in spec) if (this.streamId && this.environment !== AvoInspectorEnv.Prod) { this._eventSpecReady = this.initEventSpecModules(); } } async initEventSpecModules() { // ORDERING CONSTRAINT: All instance field assignments (this.eventSpecCache, // this.eventSpecFetcher, this._validateEvent) MUST occur inside the try block, // before the finally block clears this._eventSpecReady. The finally block sets // this._eventSpecReady = undefined to signal completion. Concurrent callers that // await this._eventSpecReady (via ensureEventSpecReady) will resume after the // finally block executes. If any field assignment were moved to or after the // finally block, those concurrent callers would find fields unpopulated, creating // a race condition where they incorrectly proceed as if no event spec is available. try { const [{ EventSpecCache }, { AvoEventSpecFetcher }, { validateEvent }] = await Promise.all([ import("./eventSpec/AvoEventSpecCache"), import("./eventSpec/AvoEventSpecFetcher"), import("./eventSpec/EventValidator") ]); this.eventSpecCache = new EventSpecCache(AvoInspector._shouldLog); this.eventSpecFetcher = new AvoEventSpecFetcher(AvoInspector._networkTimeout, AvoInspector._shouldLog, this.environment); this._validateEvent = validateEvent; if (AvoInspector._shouldLog) { console.log("[Avo Inspector] Event spec fetching and validation enabled"); if (this.publicEncryptionKey) { console.log("[Avo Inspector] Property value encryption enabled"); } } } catch (error) { if (AvoInspector._shouldLog) { console.error("[Avo Inspector] Failed to load event spec modules:", error); } } finally { this._eventSpecReady = undefined; } } async ensureEventSpecReady() { if (this.environment === AvoInspectorEnv.Prod) return null; if (this._eventSpecReady) await this._eventSpecReady; if (!this.eventSpecCache || !this.eventSpecFetcher || !this.streamId || !this._validateEvent) return null; return { cache: this.eventSpecCache, fetcher: this.eventSpecFetcher, validate: this._validateEvent, streamId: this.streamId, }; } async trackSchemaFromEvent(eventName, eventProperties) { try { if (this.avoDeduplicator.shouldRegisterEvent(eventName, eventProperties, false)) { if (AvoInspector.shouldLog) { console.log("Avo Inspector: supplied event " + eventName + " with params " + JSON.stringify(eventProperties)); } const eventSchema = await this.extractSchema(eventProperties, false); // Fetch and validate event spec (sync blocking) const validationResult = await this.fetchAndValidateEvent(eventName, eventProperties); if (validationResult) { // Spec available: merge validation results into schema and send immediately const schemaWithValidation = this.mergeValidationResults(eventSchema, validationResult); this.sendEventWithValidation(eventName, schemaWithValidation, null, null, validationResult); } else { // No spec: fall back to batched flow this.trackSchemaInternal(eventName, eventSchema, null, null); } return eventSchema; } else { if (AvoInspector.shouldLog) { console.log("Avo Inspector: Deduplicated event: " + eventName); } return []; } } catch (e) { console.error("Avo Inspector: something went wrong. Please report to support@avo.app.", e); return []; } } async _avoFunctionTrackSchemaFromEvent(eventName, eventProperties, eventId, eventHash) { try { if (this.avoDeduplicator.shouldRegisterEvent(eventName, eventProperties, true)) { if (AvoInspector.shouldLog) { console.log("Avo Inspector: supplied event " + eventName + " with params " + JSON.stringify(eventProperties)); } const eventSchema = await this.extractSchema(eventProperties, false); // Fetch and validate event spec (sync blocking) const validationResult = await this.fetchAndValidateEvent(eventName, eventProperties); if (validationResult) { // Spec available: merge validation results into schema and send immediately const schemaWithValidation = this.mergeValidationResults(eventSchema, validationResult); this.sendEventWithValidation(eventName, schemaWithValidation, eventId, eventHash, validationResult); } else { // No spec: fall back to batched flow this.trackSchemaInternal(eventName, eventSchema, eventId, eventHash); } return eventSchema; } else { if (AvoInspector.shouldLog) { console.log("Avo Inspector: Deduplicated event: " + eventName); } return []; } } catch (e) { console.error("Avo Inspector: something went wrong. Please report to support@avo.app.", e); return []; } } async trackSchema(eventName, eventSchema) { try { if (await this.avoDeduplicator.shouldRegisterSchemaFromManually(eventName, eventSchema)) { if (AvoInspector.shouldLog) { console.log("Avo Inspector: supplied event " + eventName + " with schema " + JSON.stringify(eventSchema)); } // For trackSchema we don't have raw properties, so we can't validate // Just fetch/cache spec for future use and use batched flow await this.fetchEventSpecIfNeeded(eventName); this.trackSchemaInternal(eventName, eventSchema, null, null); } else { if (AvoInspector.shouldLog) { console.log("Avo Inspector: Deduplicated event: " + eventName); } } } catch (e) { console.error("Avo Inspector: something went wrong. Please report to support@avo.app.", e); } } trackSchemaInternal(eventName, eventSchema, eventId, eventHash) { try { this.avoBatcher.handleTrackSchema(eventName, eventSchema, eventId, eventHash); } catch (e) { console.error("Avo Inspector: something went wrong. Please report to support@avo.app.", e); } } enableLogging(enable) { AvoInspector._shouldLog = enable; } async extractSchema(eventProperties, shouldLogIfEnabled = true) { try { if (this.avoDeduplicator.hasSeenEventParams(eventProperties, true)) { if (shouldLogIfEnabled && AvoInspector.shouldLog) { console.warn("Avo Inspector: WARNING! You are trying to extract schema shape that was just reported by your Avo Codegen. " + "This is an indicator of duplicate inspector reporting. " + "Please reach out to support@avo.app for advice if you are not sure how to handle this."); } } if (AvoInspector.shouldLog) { console.log("Avo Inspector: extracting schema from " + JSON.stringify(eventProperties)); } return await AvoSchemaParser.extractSchema(eventProperties, this.publicEncryptionKey, this.environment); } catch (e) { console.error("Avo Inspector: something went wrong in extractSchema. Please report to support@avo.app.", e); return []; } } setBatchSize(newBatchSize) { AvoInspector._batchSize = newBatchSize; } setBatchFlushSeconds(newBatchFlushSeconds) { AvoInspector._batchFlushSeconds = newBatchFlushSeconds; } /** * Handles branch change detection and cache storage for a fetched event spec. * This logic is shared between fetchEventSpecIfNeeded and fetchAndValidateEvent. */ handleBranchChangeAndCache(specResponse, eventName) { // Check for branch change const newBranchId = specResponse.metadata.branchId; if (this.currentBranchId !== null && this.currentBranchId !== newBranchId) { if (AvoInspector.shouldLog) { console.log(`[Avo Inspector] Branch changed from ${this.currentBranchId} to ${newBranchId}. Flushing cache.`); } this.eventSpecCache?.clear(); } this.currentBranchId = newBranchId; // Store in cache if (this.eventSpecCache && this.streamId) { this.eventSpecCache.set(this.apiKey, this.streamId, eventName, specResponse); } } /** * Fetches the event spec if spec fetching is enabled. * Used by trackSchema when we don't have raw properties to validate. * * Note: EventSpec fetching only happens in dev/staging environments. */ async fetchEventSpecIfNeeded(eventName) { const spec = await this.ensureEventSpecReady(); if (!spec) return; try { // Check cache first (includes cached empty responses) if (spec.cache.contains(this.apiKey, spec.streamId, eventName)) { const cached = spec.cache.get(this.apiKey, spec.streamId, eventName); if (!cached) { // Cached empty response — no spec exists for this event if (AvoInspector.shouldLog) { console.log(`[Avo Inspector] Cache hit (empty) for event: ${eventName}. Sending without validation.`); } } return; } // Cache miss - fetch from API (blocking) const specResponse = await spec.fetcher.fetch({ apiKey: this.apiKey, streamId: spec.streamId, eventName }); if (specResponse) { this.handleBranchChangeAndCache(specResponse, eventName); } else { // Cache the empty response so we don't re-fetch spec.cache.set(this.apiKey, spec.streamId, eventName, null); if (AvoInspector.shouldLog) { console.log(`[Avo Inspector] Event spec fetch returned null for event: ${eventName}. Cached empty response.`); } } } catch (error) { // Graceful degradation - log but don't fail if (AvoInspector.shouldLog) { console.error(`[Avo Inspector] Error fetching event spec for ${eventName}:`, error); } } } /** * Fetches event spec and validates the event against it. * Returns ValidationResult if spec is available, null otherwise. * * Note: EventSpec fetching and validation only happens in dev/staging environments. */ async fetchAndValidateEvent(eventName, eventProperties) { const spec = await this.ensureEventSpecReady(); if (!spec) return null; try { // Check cache first (includes cached empty responses) if (spec.cache.contains(this.apiKey, spec.streamId, eventName)) { const cached = spec.cache.get(this.apiKey, spec.streamId, eventName); if (cached) { if (AvoInspector.shouldLog) { console.log(`[Avo Inspector] Cache hit for event: ${eventName}`); } return spec.validate(eventProperties, cached); } else { // Cached empty response — no spec exists for this event if (AvoInspector.shouldLog) { console.log(`[Avo Inspector] Cache hit (empty) for event: ${eventName}. Sending without validation.`); } return null; } } // Cache miss - fetch from API (blocking) const specResponse = await spec.fetcher.fetch({ apiKey: this.apiKey, streamId: spec.streamId, eventName }); if (specResponse) { this.handleBranchChangeAndCache(specResponse, eventName); return spec.validate(eventProperties, specResponse); } else { // Cache the empty response so we don't re-fetch spec.cache.set(this.apiKey, spec.streamId, eventName, null); if (AvoInspector.shouldLog) { console.log(`[Avo Inspector] Event spec fetch returned null for event: ${eventName}. Cached empty response.`); } return null; } } catch (error) { // Graceful degradation - log but don't fail if (AvoInspector.shouldLog) { console.error(`[Avo Inspector] Error validating event ${eventName}:`, error); } return null; } } /** * Merges validation results into the event schema. * Adds failedEventIds or passedEventIds to each property based on validation. * Recursively merges validation results for nested children. */ mergeValidationResults(eventSchema, validationResult) { return eventSchema.map((prop) => { const propValidation = validationResult.propertyResults[prop.propertyName]; return this.mergePropertyValidation(prop, propValidation); }); } /** * Merges validation result into a single property, recursively handling children. */ mergePropertyValidation(prop, propValidation) { const result = { propertyName: prop.propertyName, propertyType: prop.propertyType }; if (prop.encryptedPropertyValue) { result.encryptedPropertyValue = prop.encryptedPropertyValue; } // Recursively merge validation results into children if (prop.children && Array.isArray(prop.children)) { result.children = prop.children.map((child) => { // Children can be strings (for array types) or objects (for nested properties) if (typeof child === 'string') { return child; } if (child && typeof child === 'object' && child.propertyName) { // Get nested validation result for this child const childValidation = propValidation?.children?.[child.propertyName]; return this.mergePropertyValidation(child, childValidation); } return child; }); } // Add validation result for this property if (propValidation) { if (propValidation.failedEventIds) { result.failedEventIds = propValidation.failedEventIds; } if (propValidation.passedEventIds) { result.passedEventIds = propValidation.passedEventIds; } } return result; } /** * Sends an event immediately with validation data (bypasses batching). * Logs validation info if shouldLog is true. */ sendEventWithValidation(eventName, eventSchema, eventId, eventHash, validationResult) { // Log validation info if shouldLog is enabled if (AvoInspector.shouldLog) { const hasFailures = eventSchema.some((p) => p.failedEventIds && p.failedEventIds.length > 0); if (hasFailures) { console.log(`[Avo Inspector] Validation failures for event "${eventName}":`, eventSchema .filter((p) => p.failedEventIds && p.failedEventIds.length > 0) .map((p) => ({ property: p.propertyName, failedEventIds: p.failedEventIds }))); } } // Create the event body with validation data const eventBody = this.avoNetworkCallsHandler.bodyForEventSchemaCall(eventName, eventSchema, eventId, eventHash, validationResult.metadata ?? undefined, validationResult.metadata?.branchId); // Send immediately (bypass batching) this.avoNetworkCallsHandler.callInspectorImmediately(eventBody, (error) => { if (error) { if (AvoInspector.shouldLog) { console.error(`[Avo Inspector] Failed to send event "${eventName}" with validation:`, error); } // Fallback: add to batch on failure (without validation data) this.avoBatcher.handleTrackSchema(eventName, eventSchema, eventId, eventHash); } else { if (AvoInspector.shouldLog) { console.log(`[Avo Inspector] Event "${eventName}" sent successfully with validation`); } } }); } } AvoInspector._batchSize = 30; AvoInspector._batchFlushSeconds = 30; AvoInspector._shouldLog = false; AvoInspector._networkTimeout = 2000;