diff --git a/docs-site b/docs-site index 59b3deb195..174ed994f4 160000 --- a/docs-site +++ b/docs-site @@ -1 +1 @@ -Subproject commit 59b3deb195b6e8eb87734136453e7e9c1016d296 +Subproject commit 174ed994f45633e4b257cdfe10e1dc76d4dc4839 diff --git a/packages/common/src/logging/index.ts b/packages/common/src/logging/index.ts new file mode 100644 index 0000000000..3e603fb7e1 --- /dev/null +++ b/packages/common/src/logging/index.ts @@ -0,0 +1,86 @@ +import { createLocalLogger } from "./local.js"; +import { noopLogger } from "./noop.js"; +import type { EventLogger, EventsSchema, LoggerContext } from "./types.js"; + +export type * from "./types.js"; + +/** + * Creates a type-safe event logger with performance profiling capabilities. + * In v5, this uses local-only logging without external service integration. + * + * @template Schema - The events schema defining allowed events and their data structures + * @param {LoggerContext} context - Context information to attach to all events + * @returns {EventLogger} A fully-featured event logger instance + * + * @example + * ```ts + * import { createLogger } from "@alchemy/common"; + * + * type MyEvents = [{ + * EventName: "user_action"; + * EventData: { action: string }; + * }]; + * + * const logger = createLogger({ + * package: "@my/package", + * version: "1.0.0" + * }); + * + * await logger.trackEvent({ + * name: "user_action", + * data: { action: "click" } + * }); + * ``` + */ +export function createLogger( + context: LoggerContext, +): EventLogger; + +export function createLogger(context: LoggerContext): EventLogger { + const innerLogger = (() => { + try { + // TODO(v5): Add Segment replacement integration once it's finalized. Local-only logging for now. + return createLocalLogger(context); + } catch (e) { + console.error("[Safe to ignore] failed to initialize metrics", e); + return noopLogger; + } + })(); + + const logger: EventLogger = { + ...innerLogger, + profiled( + name: string, + func: (...args: TArgs) => TRet, + ): (...args: TArgs) => TRet { + return function (this: any, ...args: TArgs): TRet { + const start = Date.now(); + const result = func.apply(this, args); + if (result instanceof Promise) { + return result.then((res) => { + innerLogger.trackEvent({ + name: "performance", + data: { + executionTimeMs: Date.now() - start, + functionName: name, + }, + }); + + return res; + }) as TRet; + } + + innerLogger.trackEvent({ + name: "performance", + data: { + executionTimeMs: Date.now() - start, + functionName: name, + }, + }); + return result; + }; + }, + }; + + return logger; +} diff --git a/packages/common/src/logging/local.ts b/packages/common/src/logging/local.ts new file mode 100644 index 0000000000..a1175540de --- /dev/null +++ b/packages/common/src/logging/local.ts @@ -0,0 +1,39 @@ +import type { EventsSchema, InnerLogger, LoggerContext } from "./types.js"; +import { isClientDevMode } from "./utils.js"; + +/** + * Creates a local-only logger that outputs events to the console in development mode. + * This logger does not send data to external services and is safe for all environments. + * + * @template Schema - The events schema defining allowed events and their data structures + * @param {LoggerContext} context - Context information to attach to all events + * @returns {InnerLogger} A logger instance that logs to console in dev mode + */ +export function createLocalLogger( + context: LoggerContext, +): InnerLogger { + const isDev = isClientDevMode(); + + // Generate a simple anonymous ID for local logging + const anonId = `local-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + + return { + _internal: { + ready: Promise.resolve(), + anonId, + }, + trackEvent: async ({ name, data }) => { + if (isDev) { + try { + console.log(`[${context.package}] Event: ${name}`, { + ...data, + ...context, + timestamp: new Date().toISOString(), + }); + } catch { + // Silently ignore console logging errors + } + } + }, + }; +} diff --git a/packages/common/src/logging/noop.ts b/packages/common/src/logging/noop.ts new file mode 100644 index 0000000000..dbae12f0ee --- /dev/null +++ b/packages/common/src/logging/noop.ts @@ -0,0 +1,13 @@ +import type { InnerLogger } from "./types.js"; + +/** + * No-operation logger that discards all events. + * Used as a fallback when logger initialization fails or in disabled states. + */ +export const noopLogger: InnerLogger = { + trackEvent: async () => {}, + _internal: { + ready: Promise.resolve(), + anonId: "", + }, +}; diff --git a/packages/common/src/logging/types.ts b/packages/common/src/logging/types.ts new file mode 100644 index 0000000000..a5667d79c0 --- /dev/null +++ b/packages/common/src/logging/types.ts @@ -0,0 +1,111 @@ +/** + * Schema definition for event logging. + * An array of event definitions with their names and optional data structures. + */ +export type EventsSchema = readonly { + EventName: string; + EventData?: Record; +}[]; + +type Prettify = { + [K in keyof T]: T[K]; +} & {}; + +/** + * Type-safe parameters for tracking events based on the provided schema. + * Ensures event names and data structures match the schema definition. + * When no schema is provided, allows any event with optional data. + * + * @template Schema - The events schema to validate against + */ +export type TrackEventParameters = + Schema extends readonly [] + ? { name: string; data?: any } + : { + [K in keyof Schema]: Prettify< + { name: Schema[K]["EventName"] } & ([undefined] extends [ + Schema[K]["EventData"], + ] + ? { data?: undefined } + : { data: Schema[K]["EventData"] }) + >; + }[number]; + +/** + * Main event logger interface for type-safe event tracking and performance profiling. + * + * @template Schema - The events schema defining allowed events and their data structures + */ +export interface EventLogger { + /** + * Tracks an event with type-safe validation against the schema. + * + * @param params - Event parameters including name and optional data + * @returns Promise that resolves when the event is tracked + */ + trackEvent( + params: TrackEventParameters< + Schema extends readonly [] ? [] : [...Schema, PerformanceEvent] + >, + ): Promise; + + /** + * Wraps a function to automatically track its execution time as a performance event. + * + * @template TArgs - Function argument types + * @template TRet - Function return type + * @param name - Name identifier for the profiled function + * @param func - Function to wrap with performance tracking + * @returns Wrapped function that tracks execution time + */ + profiled( + name: string, + func: (...args: TArgs) => TRet, + ): (...args: TArgs) => TRet; + + /** Internal properties for logger state and configuration */ + _internal: { + /** Promise that resolves when logger is ready for use */ + ready: Promise; + /** Anonymous identifier for this logger instance */ + anonId: string; + }; +} + +/** + * Internal logger interface without the profiled method. + * Used internally by different logger implementations. + * + * @template Schema - The events schema defining allowed events and their data structures + */ +export type InnerLogger = Omit< + EventLogger, + "profiled" +>; + +/** + * Context information attached to all logged events. + * Provides metadata about the package and version generating events. + */ +export type LoggerContext = { + /** Name of the package generating events */ + package: string; + /** Version of the package generating events */ + version: string; + /** Additional context properties as key-value pairs */ + [key: string]: string; +}; + +/** + * Built-in performance event schema for tracking function execution times. + * Automatically included in all event schemas for profiled function tracking. + */ +export type PerformanceEvent = { + EventName: "performance"; + EventData: { + /** Execution time in milliseconds */ + executionTimeMs: number; + /** Name of the function being profiled */ + functionName: string; + }; +}; diff --git a/packages/common/src/logging/utils.ts b/packages/common/src/logging/utils.ts new file mode 100644 index 0000000000..8b8800a1eb --- /dev/null +++ b/packages/common/src/logging/utils.ts @@ -0,0 +1,31 @@ +// just in case we're in a setting that doesn't have these types defined +declare const __DEV__: boolean | undefined; +declare const window: Window | undefined; + +/** + * Detects if the current environment is in development mode. + * Checks multiple common development environment indicators. + * + * @returns {boolean} True if running in development mode + */ +export function isClientDevMode() { + if (typeof __DEV__ !== "undefined" && __DEV__) { + return true; + } + + if ( + typeof process !== "undefined" && + process.env.NODE_ENV === "development" + ) { + return true; + } + + if ( + typeof window !== "undefined" && + window.location?.hostname?.includes("localhost") + ) { + return true; + } + + return false; +} diff --git a/packages/common/tests/logging/createLogger.test.ts b/packages/common/tests/logging/createLogger.test.ts new file mode 100644 index 0000000000..4a04a33d2d --- /dev/null +++ b/packages/common/tests/logging/createLogger.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createLogger } from "../../src/logging/index.js"; +import type { LoggerContext } from "../../src/logging/types.js"; + +const mockContext: LoggerContext = { + package: "@test/package", + version: "1.0.0", +}; + +const mockConsoleLog = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(mockConsoleLog); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("createLogger", () => { + it("should track events in development mode", async () => { + vi.stubGlobal("process", { env: { NODE_ENV: "development" } }); + + const logger = createLogger(mockContext); + await logger.trackEvent({ + name: "user_action", + data: { action: "click", count: 1 }, + }); + + expect(mockConsoleLog).toHaveBeenCalledWith( + "[@test/package] Event: user_action", + expect.objectContaining({ + action: "click", + count: 1, + package: "@test/package", + version: "1.0.0", + timestamp: expect.any(String), + }), + ); + }); + + it("should not log in production environments", async () => { + vi.stubGlobal("process", { env: { NODE_ENV: "production" } }); + vi.stubGlobal("__DEV__", false); + + const logger = createLogger(mockContext); + await logger.trackEvent({ name: "test_event" }); + + expect(mockConsoleLog).not.toHaveBeenCalled(); + }); + + it("should profile synchronous functions", () => { + vi.stubGlobal("process", { env: { NODE_ENV: "development" } }); + + const logger = createLogger(mockContext); + const testFunction = (x: number) => x * 2; + + const profiledFunction = logger.profiled("testFunc", testFunction); + const result = profiledFunction(5); + + expect(result).toBe(10); + expect(mockConsoleLog).toHaveBeenCalledWith( + "[@test/package] Event: performance", + expect.objectContaining({ + executionTimeMs: expect.any(Number), + functionName: "testFunc", + }), + ); + }); + + it("should profile async functions", async () => { + vi.stubGlobal("process", { env: { NODE_ENV: "development" } }); + + const logger = createLogger(mockContext); + const asyncFunction = async (x: number) => { + await new Promise((resolve) => setTimeout(resolve, 1)); + return x * 2; + }; + + const profiledFunction = logger.profiled("asyncFunc", asyncFunction); + const result = await profiledFunction(5); + + expect(result).toBe(10); + expect(mockConsoleLog).toHaveBeenCalledWith( + "[@test/package] Event: performance", + expect.objectContaining({ + executionTimeMs: expect.any(Number), + functionName: "asyncFunc", + }), + ); + }); +});