diff --git a/src/core/analytics.ts b/src/core/analytics.ts index dfd628d..a061fe2 100644 --- a/src/core/analytics.ts +++ b/src/core/analytics.ts @@ -33,6 +33,8 @@ import { getTimestamp, deepMerge, } from "../utils/helpers"; +import { resolveEndpoint, detectEnvironment } from "../utils/config-loader"; +import type { EndpointConfig } from "./types"; /** * Analytics builder class @@ -208,13 +210,26 @@ export class Analytics implements IAnalytics { throw new InitializationError("Analytics already initialized"); } + this.logger.info("Loading Configuration"); + // Check Do Not Track + this.logger.info("Check if Analytics enabled"); if (this.config.respectDoNotTrack && isDoNotTrackEnabled()) { this.logger.warn("Do Not Track is enabled, analytics will be disabled"); this.enabled = false; return; } + this.logger.info("Configuration loaded"); + + // Log environment detection + const currentEnv = this.config.environment || detectEnvironment(); + this.logger.info(`Identified environment: ${currentEnv}`); + + if (currentEnv !== "development" && currentEnv !== "local") { + this.logger.info("Identified non-dev environment, analytics auto load wouldn't be attempted."); + } + // Create plugin context const context: PluginContext = { config: this.config, @@ -225,6 +240,14 @@ export class Analytics implements IAnalytics { getUserId: this.getUserId.bind(this), }; + // Show consent popup if configured + if (this.config.showConsentPopup) { + this.logger.info("Display Tracker Popup"); + } + + // Hook event trackers + this.logger.info("Hook Event Trackers"); + // Initialize all plugins for (const plugin of this.plugins) { try { @@ -237,11 +260,20 @@ export class Analytics implements IAnalytics { // Set up flush interval for deferred submission if (this.config.submissionStrategy === "DEFER") { + this.logger.info('Hook Handlers to flush events (use when submissionStrategy is configured as "DEFER"'); this.flushInterval = setInterval(() => { this.flush(); }, this.config.flushInterval); } + // Location detection + if (this.config.enableLocation) { + this.logger.info("Find User Location Details"); + } + + // Session and user initialization + this.logger.info("Initiate Session and Anonymous User ID"); + // Set up browser-only unload handlers if (getEnvironmentType() === "browser") { window.addEventListener("beforeunload", () => { @@ -448,14 +480,55 @@ export class Analytics implements IAnalytics { /** * Get analytics endpoint + * Returns the resolved endpoint, handling both string and multi-endpoint configs */ private getEndpoint(): string { - if (this.config.endpoint) { + // Use resolved endpoint if available (set during async initialization) + if (this.config.resolvedEndpoint) { + return this.config.resolvedEndpoint; + } + + // Handle string endpoint (backward compatible) + if (typeof this.config.endpoint === "string") { return this.config.endpoint; } + // Handle object endpoint (multi-endpoint config) - synchronous fallback + if (this.config.endpoint && typeof this.config.endpoint === "object") { + const endpoints = this.config.endpoint as EndpointConfig; + const currentEnv = this.config.environment || detectEnvironment(); + + if (endpoints[currentEnv]) { + return endpoints[currentEnv]; + } + + // Try fallback environments + const fallbackOrder = ["development", "local", "staging", "production"]; + for (const fallbackEnv of fallbackOrder) { + if (endpoints[fallbackEnv]) { + return endpoints[fallbackEnv]; + } + } + } + // Default Armco endpoint if apiKey is provided - return "https://telemetry.armco.dev/events/add"; + if (this.config.apiKey) { + return "https://telemetry.armco.dev/events/add"; + } + + // Final fallback + return "http://localhost:5001/events/add"; + } + + /** + * Resolve endpoint asynchronously with health check + * Call this before init() for full endpoint resolution with health checking + */ + async resolveEndpointAsync(): Promise { + this.logger.info("Resolving endpoint with health check..."); + const resolved = await resolveEndpoint(this.config); + this.config.resolvedEndpoint = resolved; + this.logger.info(`Resolved endpoint: ${resolved}`); } /** diff --git a/src/core/types.ts b/src/core/types.ts index 6d4f684..392e768 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -7,6 +7,16 @@ */ export type Environment = "browser" | "node" | "unknown"; +/** + * Runtime environment names for endpoint configuration + */ +export type EnvironmentName = "development" | "staging" | "production" | "test" | "local" | string; + +/** + * Multi-endpoint configuration mapping environment names to URLs + */ +export type EndpointConfig = Record; + /** * Submission strategy for events */ @@ -105,8 +115,22 @@ export interface LocationData { */ export interface AnalyticsConfig { apiKey?: string; - endpoint?: string; + /** + * Single endpoint URL (backward compatible) or multi-endpoint config + * When object: keys are environment names, values are URLs + * Example: { "production": "https://telemetry.armco.dev/events/add", "development": "http://localhost:5001/events/add" } + */ + endpoint?: string | EndpointConfig; + /** + * Resolved endpoint URL (set internally after endpoint resolution) + */ + resolvedEndpoint?: string; updateEndpoint?: string; // Endpoint for updating anonymous events with user identity + /** + * Current environment name to use for endpoint resolution + * If not set, will be auto-detected from NODE_ENV or window location + */ + environment?: EnvironmentName; hostProjectName?: string; trackEvents?: string[]; submissionStrategy?: SubmissionStrategy; diff --git a/src/index.ts b/src/index.ts index cb6a9cf..03308cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,7 +87,13 @@ export { deepMerge, } from "./utils/helpers"; -export { loadConfigFromFile, loadConfig } from "./utils/config-loader"; +export { + loadConfigFromFile, + loadConfig, + detectEnvironment, + checkEndpointHealth, + resolveEndpoint +} from "./utils/config-loader"; // Create helper function import { AnalyticsBuilder as Builder } from "./core/analytics"; diff --git a/src/plugins/auto-track/click.ts b/src/plugins/auto-track/click.ts index 0c00594..e5db32a 100644 --- a/src/plugins/auto-track/click.ts +++ b/src/plugins/auto-track/click.ts @@ -35,8 +35,14 @@ export class ClickTrackingPlugin implements Plugin { } this.context = context; + this.logger.info("Attaching Click handlers"); this.attachHandlers(); - this.logger.info("Click tracking initialized"); + + // Count trackable elements + const trackableElements = document.querySelectorAll(TRACKED_ELEMENTS.join(", ")); + this.logger.info(`Found ${trackableElements.length} items that can be clicked!`); + this.logger.info("Dynamically added elements will be added to this list."); + this.logger.info("Click handlers Attached"); } /** diff --git a/src/plugins/auto-track/page.ts b/src/plugins/auto-track/page.ts index c449933..05efda6 100644 --- a/src/plugins/auto-track/page.ts +++ b/src/plugins/auto-track/page.ts @@ -27,6 +27,7 @@ export class PageTrackingPlugin implements Plugin { this.attachHandlers(); // Track initial page view + this.logger.info("Logging page load"); this.trackCurrentPage(); this.logger.info("Page tracking initialized"); diff --git a/src/plugins/enrichment/user.ts b/src/plugins/enrichment/user.ts index dec7345..c6e3650 100644 --- a/src/plugins/enrichment/user.ts +++ b/src/plugins/enrichment/user.ts @@ -29,6 +29,12 @@ export class UserPlugin implements Plugin { if (!this.user) { this.generateAnonymousId(); } + + // Log user tracking info + const userId = this.getUserId(); + if (userId) { + this.logger.info(`Tracking User as ${userId}`); + } } /** @@ -198,8 +204,11 @@ export class UserPlugin implements Plugin { // If no explicit update endpoint, use default based on config if (config.apiKey) { updateEndpoint = "https://telemetry.armco.dev/events/tag"; - } else if (config.endpoint) { - // Derive update endpoint from main endpoint + } else if (config.resolvedEndpoint) { + // Derive update endpoint from resolved endpoint + updateEndpoint = config.resolvedEndpoint.replace("/add", "/tag"); + } else if (typeof config.endpoint === "string") { + // Derive update endpoint from main endpoint (string only) updateEndpoint = config.endpoint.replace("/add", "/tag"); } else { this.logger.warn("No update endpoint configured, skipping event tagging"); @@ -212,7 +221,7 @@ export class UserPlugin implements Plugin { `Updating anonymous events (${anonymousId}) with user identity (${email})` ); - const response = await transport.update(updateEndpoint, { + const response = await transport.update!(updateEndpoint, { email, anonymousId, }); diff --git a/src/utils/config-loader.ts b/src/utils/config-loader.ts index 93e971d..e97eae4 100644 --- a/src/utils/config-loader.ts +++ b/src/utils/config-loader.ts @@ -9,11 +9,12 @@ * TODO(Config): Add support for environment-specific configs (analyticsrc.dev.json, analyticsrc.prod.json) */ -import type { AnalyticsConfig } from "../core/types"; -import { getEnvironmentType } from "./helpers"; +import type { AnalyticsConfig, EndpointConfig, EnvironmentName } from "../core/types"; +import { getEnvironmentType, getEnvironment } from "./helpers"; import { getLogger } from "./logging"; const CONFIG_FILE_NAME = "analyticsrc"; +const DEFAULT_FALLBACK_ENDPOINT = "http://localhost:5001/events/add"; const logger = getLogger(); /** @@ -103,3 +104,153 @@ export async function loadConfig( ...overrides, }; } + +/** + * Detect current environment name from various sources + */ +export function detectEnvironment(): EnvironmentName { + const envType = getEnvironmentType(); + + if (envType === "node") { + // Check NODE_ENV + const nodeEnv = process.env.NODE_ENV?.toLowerCase(); + if (nodeEnv) { + return nodeEnv as EnvironmentName; + } + return "development"; + } + + if (envType === "browser") { + // Detect from hostname + const hostname = window.location.hostname; + + if (hostname === "localhost" || hostname === "127.0.0.1") { + return "development"; + } + + if (hostname.includes("staging") || hostname.includes("stage")) { + return "staging"; + } + + if (hostname.includes("test") || hostname.includes("qa")) { + return "test"; + } + + // Default to production for non-local environments + return "production"; + } + + return "development"; +} + +/** + * Check if an endpoint URL is reachable + * Returns true if endpoint responds, false otherwise + */ +export async function checkEndpointHealth(url: string): Promise { + try { + const envType = getEnvironmentType(); + + if (envType === "node") { + // Node.js: Use fetch with timeout + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + try { + const response = await fetch(url.replace("/events/add", "/health"), { + method: "GET", + signal: controller.signal, + }); + clearTimeout(timeout); + return response.ok || response.status === 404; // 404 is acceptable - endpoint exists + } catch { + clearTimeout(timeout); + return false; + } + } else if (envType === "browser") { + // Browser: Use fetch with timeout + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + try { + const response = await fetch(url.replace("/events/add", "/health"), { + method: "GET", + mode: "no-cors", // Avoid CORS issues for health check + signal: controller.signal, + }); + clearTimeout(timeout); + // In no-cors mode, we can't read response but if it doesn't throw, endpoint is reachable + return true; + } catch { + clearTimeout(timeout); + return false; + } + } + + return true; // Default to true for unknown environments + } catch { + return false; + } +} + +/** + * Resolve endpoint URL from config based on environment + * Performs health check and falls back to localhost if endpoint is unreachable + */ +export async function resolveEndpoint( + config: Partial +): Promise { + const currentEnv = config.environment || detectEnvironment(); + logger.info(`Identified environment: ${currentEnv}`); + + // If endpoint is a string (backward compatible), return as-is + if (typeof config.endpoint === "string") { + logger.debug(`Using single endpoint: ${config.endpoint}`); + return config.endpoint; + } + + // If endpoint is an object (multi-endpoint config) + if (config.endpoint && typeof config.endpoint === "object") { + const endpoints = config.endpoint as EndpointConfig; + const envEndpoint = endpoints[currentEnv]; + + if (envEndpoint) { + logger.info(`Found endpoint for environment '${currentEnv}': ${envEndpoint}`); + + // Check if endpoint is reachable + const isHealthy = await checkEndpointHealth(envEndpoint); + + if (isHealthy) { + logger.info(`Endpoint ${envEndpoint} is reachable`); + return envEndpoint; + } else { + logger.warn(`Endpoint ${envEndpoint} is not reachable, falling back to ${DEFAULT_FALLBACK_ENDPOINT}`); + return DEFAULT_FALLBACK_ENDPOINT; + } + } else { + // Try to find a fallback in order of preference + const fallbackOrder: EnvironmentName[] = ["development", "local", "staging", "production"]; + + for (const fallbackEnv of fallbackOrder) { + if (endpoints[fallbackEnv]) { + logger.warn(`No endpoint configured for '${currentEnv}', using '${fallbackEnv}' endpoint: ${endpoints[fallbackEnv]}`); + return endpoints[fallbackEnv]; + } + } + + // No endpoint found, use default fallback + logger.warn(`No endpoint found in configuration, using fallback: ${DEFAULT_FALLBACK_ENDPOINT}`); + return DEFAULT_FALLBACK_ENDPOINT; + } + } + + // No endpoint configured at all, use default + if (config.apiKey) { + const defaultEndpoint = "https://telemetry.armco.dev/events/add"; + logger.info(`Using default Armco endpoint: ${defaultEndpoint}`); + return defaultEndpoint; + } + + logger.warn(`No endpoint configured, using fallback: ${DEFAULT_FALLBACK_ENDPOINT}`); + return DEFAULT_FALLBACK_ENDPOINT; +} diff --git a/src/utils/logging.ts b/src/utils/logging.ts index 4e3c8b8..07dd280 100644 --- a/src/utils/logging.ts +++ b/src/utils/logging.ts @@ -11,7 +11,7 @@ export class Logger { private level: LogLevel; private prefix: string; - constructor(level: LogLevel = "info", prefix = "[Analytics]") { + constructor(level: LogLevel = "info", prefix = "[ANALYTICS]") { this.level = level; this.prefix = prefix; }