Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions packages/common/src/logging/index.ts
Original file line number Diff line number Diff line change
@@ -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<Schema>} A fully-featured event logger instance
*
* @example
* ```ts
* import { createLogger } from "@alchemy/common";
*
* type MyEvents = [{
* EventName: "user_action";
* EventData: { action: string };
* }];
*
* const logger = createLogger<MyEvents>({
* package: "@my/package",
* version: "1.0.0"
* });
*
* await logger.trackEvent({
* name: "user_action",
* data: { action: "click" }
* });
* ```
*/
export function createLogger<Schema extends EventsSchema = []>(
context: LoggerContext,
): EventLogger<Schema>;

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<TArgs extends any[], TRet>(
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;
}
39 changes: 39 additions & 0 deletions packages/common/src/logging/local.ts
Original file line number Diff line number Diff line change
@@ -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<Schema>} A logger instance that logs to console in dev mode
*/
export function createLocalLogger<Schema extends EventsSchema = []>(
context: LoggerContext,
): InnerLogger<Schema> {
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
}
}
},
};
}
13 changes: 13 additions & 0 deletions packages/common/src/logging/noop.ts
Original file line number Diff line number Diff line change
@@ -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<any> = {
trackEvent: async () => {},
_internal: {
ready: Promise.resolve(),
anonId: "",
},
};
111 changes: 111 additions & 0 deletions packages/common/src/logging/types.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;
}[];

type Prettify<T> = {
[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 EventsSchema> =
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<Schema extends EventsSchema = []> {
/**
* 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<void>;

/**
* 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<TArgs extends any[], TRet>(
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<unknown>;
/** 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<Schema extends EventsSchema> = Omit<
EventLogger<Schema>,
"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;
};
};
31 changes: 31 additions & 0 deletions packages/common/src/logging/utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
93 changes: 93 additions & 0 deletions packages/common/tests/logging/createLogger.test.ts
Original file line number Diff line number Diff line change
@@ -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",
}),
);
});
});
Loading