react-native-avo-inspector
Version:
[](https://badge.fury.io/js/react-native-avo-inspector)
471 lines (470 loc) • 21.4 kB
JavaScript
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;