edsger-logs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # edsger-logs
2
+
3
+ Send product logs to [Edsger](https://edsger.ai) for user behavior analytics and product improvement.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install edsger-logs
9
+ ```
10
+
11
+ ## Quick Start (Recommended)
12
+
13
+ Use `createLogger()` for non-blocking, fire-and-forget logging that never interferes with your product's normal flow:
14
+
15
+ ```ts
16
+ import { createLogger } from 'edsger-logs'
17
+
18
+ const logger = createLogger({
19
+ productId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
20
+ userId: 'user-uuid',
21
+ sessionId: 'sess_abc123',
22
+ })
23
+
24
+ // Fire-and-forget — no await, no try/catch, no this-binding issues
25
+ logger.log('page_view', 'User viewed feature list', { metadata: { page: '/features' } })
26
+ logger.log('button_click', 'Clicked create feature')
27
+
28
+ // Safe to destructure
29
+ const { log, flush, dispose } = logger
30
+ log('search', 'Searched for auth')
31
+
32
+ // Create a scoped child logger with different context (immutable)
33
+ const adminLogger = logger.withContext({ userId: 'admin-uuid' })
34
+ adminLogger.log('settings_changed', 'Updated notification preferences')
35
+
36
+ // On app shutdown
37
+ await flush()
38
+ dispose()
39
+ ```
40
+
41
+ ## API
42
+
43
+ ### `createLogger(options)` (Recommended)
44
+
45
+ Closure-based non-blocking logger. No `this`, no classes, safe to destructure. Buffers logs in memory and flushes to server every 5 seconds or when buffer reaches 50 entries.
46
+
47
+ ```ts
48
+ const logger = createLogger({
49
+ productId: 'xxx', // Required
50
+ userId: 'user-id', // Optional: default user for all logs
51
+ sessionId: 'sess-id', // Optional: default session for all logs
52
+ endpoint: '...', // Optional: custom endpoint
53
+ flushInterval: 5000, // Optional: flush every N ms (default: 5000)
54
+ maxBufferSize: 50, // Optional: auto-flush at N entries (default: 50)
55
+ onError: (err) => {}, // Optional: error callback (silent by default)
56
+ })
57
+
58
+ logger.log('event', 'message') // Non-blocking, never throws
59
+ logger.log('event', 'msg', { metadata }) // With metadata
60
+ logger.log('event', 'msg', { userId }) // Override user per-call
61
+
62
+ const child = logger.withContext({ sessionId: 'new-sess' }) // Immutable child
63
+ await logger.flush() // Manual flush (e.g. on shutdown)
64
+ logger.dispose() // Stop timer, release resources
65
+ ```
66
+
67
+ ### `sendLog(options)`
68
+
69
+ Low-level async function. Send a single log entry. Returns `Promise<SendLogResult>`.
70
+ **Note:** This is async and throws on error — use `createLogger()` instead for non-blocking usage.
71
+
72
+ | Option | Type | Required | Description |
73
+ |--------|------|----------|-------------|
74
+ | `productId` | `string` | Yes | Product UUID |
75
+ | `logType` | `string` | Yes | Event type (max 50 chars), e.g. `page_view`, `button_click`, `error` |
76
+ | `message` | `string` | Yes | Human-readable description (max 5000 chars) |
77
+ | `userId` | `string` | No | Authenticated user UUID |
78
+ | `sessionId` | `string` | No | Session identifier to group related events |
79
+ | `metadata` | `object` | No | Structured data for the event |
80
+
81
+ **Returns:**
82
+
83
+ ```ts
84
+ { id: string; createdAt: string }
85
+ ```
86
+
87
+ ### `sendLogs(options)`
88
+
89
+ Send multiple log entries in a single request. Returns `Promise<BatchSendLogResult>`.
90
+
91
+ ```ts
92
+ import { sendLogs } from 'edsger-logs'
93
+
94
+ await sendLogs({
95
+ logs: [
96
+ { productId: '...', logType: 'page_view', message: 'Viewed home' },
97
+ { productId: '...', logType: 'button_click', message: 'Clicked create' },
98
+ ],
99
+ })
100
+ ```
101
+
102
+ Max 100 logs per batch. Returns `{ count: number }`.
103
+
104
+ ## Error Handling
105
+
106
+ ```ts
107
+ import { sendLog, LogError } from 'edsger-logs'
108
+
109
+ try {
110
+ await sendLog({ ... })
111
+ } catch (err) {
112
+ if (err instanceof LogError) {
113
+ console.error(err.message, err.statusCode)
114
+ }
115
+ }
116
+ ```
117
+
118
+ ## Requirements
119
+
120
+ - Node.js >= 18 (uses native `fetch`)
121
+
122
+ ## License
123
+
124
+ ISC
@@ -0,0 +1,34 @@
1
+ import type { SendLogOptions, SendLogResult, BatchSendLogOptions, BatchSendLogResult } from './types.js';
2
+ /**
3
+ * Send a single product log entry.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * import { sendLog } from 'edsger-logs'
8
+ *
9
+ * await sendLog({
10
+ * productId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
11
+ * logType: 'page_view',
12
+ * message: 'User viewed feature list',
13
+ * sessionId: 'sess_abc123',
14
+ * metadata: { page: '/features', referrer: '/dashboard' },
15
+ * })
16
+ * ```
17
+ */
18
+ export declare function sendLog(options: SendLogOptions, endpoint?: string): Promise<SendLogResult>;
19
+ /**
20
+ * Send multiple product log entries in a single request.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * import { sendLogs } from 'edsger-logs'
25
+ *
26
+ * await sendLogs({
27
+ * logs: [
28
+ * { productId: '...', logType: 'page_view', message: 'Viewed home' },
29
+ * { productId: '...', logType: 'button_click', message: 'Clicked create' },
30
+ * ],
31
+ * })
32
+ * ```
33
+ */
34
+ export declare function sendLogs(options: BatchSendLogOptions, endpoint?: string): Promise<BatchSendLogResult>;
package/dist/client.js ADDED
@@ -0,0 +1,110 @@
1
+ import { LogError } from './types.js';
2
+ const DEFAULT_ENDPOINT = 'https://ktkogvogdaffjmvrewiu.supabase.co/functions/v1/product-logs';
3
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4
+ /**
5
+ * Send a single product log entry.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { sendLog } from 'edsger-logs'
10
+ *
11
+ * await sendLog({
12
+ * productId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
13
+ * logType: 'page_view',
14
+ * message: 'User viewed feature list',
15
+ * sessionId: 'sess_abc123',
16
+ * metadata: { page: '/features', referrer: '/dashboard' },
17
+ * })
18
+ * ```
19
+ */
20
+ export async function sendLog(options, endpoint) {
21
+ validate(options);
22
+ const res = await fetch(endpoint || DEFAULT_ENDPOINT, {
23
+ method: 'POST',
24
+ headers: { 'Content-Type': 'application/json' },
25
+ body: JSON.stringify({
26
+ product_id: options.productId,
27
+ log_type: options.logType.trim(),
28
+ message: options.message.trim(),
29
+ user_id: options.userId || undefined,
30
+ session_id: options.sessionId || undefined,
31
+ metadata: options.metadata || {},
32
+ }),
33
+ });
34
+ const data = await res.json();
35
+ if (!res.ok) {
36
+ throw new LogError(data.error || 'Failed to send log', res.status);
37
+ }
38
+ return {
39
+ id: data.log.id,
40
+ createdAt: data.log.created_at,
41
+ };
42
+ }
43
+ /**
44
+ * Send multiple product log entries in a single request.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * import { sendLogs } from 'edsger-logs'
49
+ *
50
+ * await sendLogs({
51
+ * logs: [
52
+ * { productId: '...', logType: 'page_view', message: 'Viewed home' },
53
+ * { productId: '...', logType: 'button_click', message: 'Clicked create' },
54
+ * ],
55
+ * })
56
+ * ```
57
+ */
58
+ export async function sendLogs(options, endpoint) {
59
+ if (!options.logs || options.logs.length === 0) {
60
+ throw new LogError('logs array is required and must not be empty', 400);
61
+ }
62
+ if (options.logs.length > 100) {
63
+ throw new LogError('Cannot send more than 100 logs at once', 400);
64
+ }
65
+ for (const log of options.logs) {
66
+ validate(log);
67
+ }
68
+ const res = await fetch((endpoint || DEFAULT_ENDPOINT) + '/batch', {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({
72
+ logs: options.logs.map((log) => ({
73
+ product_id: log.productId,
74
+ log_type: log.logType.trim(),
75
+ message: log.message.trim(),
76
+ user_id: log.userId || undefined,
77
+ session_id: log.sessionId || undefined,
78
+ metadata: log.metadata || {},
79
+ })),
80
+ }),
81
+ });
82
+ const data = await res.json();
83
+ if (!res.ok) {
84
+ throw new LogError(data.error || 'Failed to send logs', res.status);
85
+ }
86
+ return { count: data.count };
87
+ }
88
+ function validate(options) {
89
+ if (!options.productId) {
90
+ throw new LogError('productId is required', 400);
91
+ }
92
+ if (!UUID_RE.test(options.productId)) {
93
+ throw new LogError('productId must be a valid UUID', 400);
94
+ }
95
+ if (!options.logType?.trim()) {
96
+ throw new LogError('logType is required', 400);
97
+ }
98
+ if (options.logType.length > 50) {
99
+ throw new LogError('logType must be 50 characters or less', 400);
100
+ }
101
+ if (!options.message?.trim()) {
102
+ throw new LogError('message is required', 400);
103
+ }
104
+ if (options.message.length > 5000) {
105
+ throw new LogError('message must be 5000 characters or less', 400);
106
+ }
107
+ if (options.userId && !UUID_RE.test(options.userId)) {
108
+ throw new LogError('userId must be a valid UUID', 400);
109
+ }
110
+ }
@@ -0,0 +1,3 @@
1
+ export { sendLog, sendLogs } from './client.js';
2
+ export { createLogger } from './logger.js';
3
+ export { LogError, type SendLogOptions, type SendLogResult, type BatchSendLogOptions, type BatchSendLogResult, type LoggerOptions, type LogContext, type Logger, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { sendLog, sendLogs } from './client.js';
2
+ export { createLogger } from './logger.js';
3
+ export { LogError, } from './types.js';
@@ -0,0 +1,27 @@
1
+ import type { LoggerOptions, Logger } from './types.js';
2
+ /**
3
+ * Create a non-blocking product logger.
4
+ *
5
+ * Uses closure-based state — no `this` binding issues, safe to destructure.
6
+ * Buffers logs in memory and flushes periodically. Never throws, never blocks.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { createLogger } from 'edsger-logs'
11
+ *
12
+ * const logger = createLogger({ productId: 'xxx', userId: 'user-1' })
13
+ *
14
+ * // Fire-and-forget — no await, no try/catch
15
+ * logger.log('page_view', 'Viewed feature list', { metadata: { page: '/features' } })
16
+ * logger.log('button_click', 'Clicked create')
17
+ *
18
+ * // Create a scoped child logger with different context
19
+ * const sessionLogger = logger.withContext({ sessionId: 'sess_abc' })
20
+ * sessionLogger.log('search', 'Searched for auth')
21
+ *
22
+ * // On shutdown
23
+ * await logger.flush()
24
+ * logger.dispose()
25
+ * ```
26
+ */
27
+ export declare function createLogger(options: LoggerOptions): Logger;
package/dist/logger.js ADDED
@@ -0,0 +1,108 @@
1
+ const DEFAULT_ENDPOINT = 'https://ktkogvogdaffjmvrewiu.supabase.co/functions/v1/product-logs';
2
+ const DEFAULT_FLUSH_INTERVAL = 5_000;
3
+ const DEFAULT_MAX_BUFFER_SIZE = 50;
4
+ const isBrowser = typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function';
5
+ /**
6
+ * Create a non-blocking product logger.
7
+ *
8
+ * Uses closure-based state — no `this` binding issues, safe to destructure.
9
+ * Buffers logs in memory and flushes periodically. Never throws, never blocks.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * import { createLogger } from 'edsger-logs'
14
+ *
15
+ * const logger = createLogger({ productId: 'xxx', userId: 'user-1' })
16
+ *
17
+ * // Fire-and-forget — no await, no try/catch
18
+ * logger.log('page_view', 'Viewed feature list', { metadata: { page: '/features' } })
19
+ * logger.log('button_click', 'Clicked create')
20
+ *
21
+ * // Create a scoped child logger with different context
22
+ * const sessionLogger = logger.withContext({ sessionId: 'sess_abc' })
23
+ * sessionLogger.log('search', 'Searched for auth')
24
+ *
25
+ * // On shutdown
26
+ * await logger.flush()
27
+ * logger.dispose()
28
+ * ```
29
+ */
30
+ export function createLogger(options) {
31
+ const productId = options.productId;
32
+ const endpoint = options.endpoint || DEFAULT_ENDPOINT;
33
+ const maxBufferSize = options.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE;
34
+ const onError = options.onError;
35
+ // Shared mutable buffer (shared across parent + child loggers)
36
+ const buffer = [];
37
+ let flushing = false;
38
+ const timer = setInterval(() => {
39
+ flush().catch(() => { });
40
+ }, options.flushInterval ?? DEFAULT_FLUSH_INTERVAL);
41
+ if (typeof timer === 'object' && 'unref' in timer) {
42
+ timer.unref();
43
+ }
44
+ function flush() {
45
+ if (buffer.length === 0 || flushing)
46
+ return Promise.resolve();
47
+ flushing = true;
48
+ // Snapshot and clear — avoids concurrent mutation
49
+ const batch = buffer.splice(0);
50
+ const body = JSON.stringify({ logs: batch });
51
+ if (isBrowser) {
52
+ const blob = new Blob([body], { type: 'application/json' });
53
+ const queued = navigator.sendBeacon(endpoint + '/batch', blob);
54
+ if (!queued) {
55
+ onError?.(new Error('sendBeacon failed to queue'));
56
+ }
57
+ flushing = false;
58
+ return Promise.resolve();
59
+ }
60
+ // Node.js fallback
61
+ return fetch(endpoint + '/batch', {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body,
65
+ })
66
+ .then(() => { })
67
+ .catch((error) => {
68
+ onError?.(error instanceof Error ? error : new Error(String(error)));
69
+ })
70
+ .finally(() => {
71
+ flushing = false;
72
+ });
73
+ }
74
+ // Flush remaining logs when the page is about to close (browser only)
75
+ const beforeUnloadHandler = isBrowser
76
+ ? () => { flush().catch(() => { }); }
77
+ : undefined;
78
+ if (beforeUnloadHandler) {
79
+ addEventListener('beforeunload', beforeUnloadHandler);
80
+ }
81
+ function dispose() {
82
+ clearInterval(timer);
83
+ if (beforeUnloadHandler) {
84
+ removeEventListener('beforeunload', beforeUnloadHandler);
85
+ }
86
+ }
87
+ // Factory for creating a logger bound to specific defaults
88
+ function makeLogger(defaultUserId, defaultSessionId) {
89
+ const log = (logType, message, context) => {
90
+ if (!logType || !message)
91
+ return;
92
+ buffer.push({
93
+ product_id: productId,
94
+ log_type: logType.slice(0, 50),
95
+ message: message.slice(0, 5000),
96
+ user_id: context?.userId ?? defaultUserId,
97
+ session_id: context?.sessionId ?? defaultSessionId,
98
+ metadata: context?.metadata ?? {},
99
+ });
100
+ if (buffer.length >= maxBufferSize) {
101
+ flush().catch(() => { });
102
+ }
103
+ };
104
+ const withContext = (ctx) => makeLogger(ctx.userId ?? defaultUserId, ctx.sessionId ?? defaultSessionId);
105
+ return { log, flush, withContext, dispose };
106
+ }
107
+ return makeLogger(options.userId, options.sessionId);
108
+ }
@@ -0,0 +1,63 @@
1
+ export interface SendLogOptions {
2
+ /** Product ID (UUID) */
3
+ productId: string;
4
+ /** Log type, e.g. "page_view", "button_click", "error", "feature_used" */
5
+ logType: string;
6
+ /** Human-readable description of the event */
7
+ message: string;
8
+ /** Optional user ID (UUID) for authenticated users */
9
+ userId?: string;
10
+ /** Optional session ID to group related events */
11
+ sessionId?: string;
12
+ /** Optional structured metadata for the event */
13
+ metadata?: Record<string, unknown>;
14
+ }
15
+ export interface SendLogResult {
16
+ id: string;
17
+ createdAt: string;
18
+ }
19
+ export interface BatchSendLogOptions {
20
+ /** Array of log entries to send */
21
+ logs: SendLogOptions[];
22
+ }
23
+ export interface BatchSendLogResult {
24
+ count: number;
25
+ }
26
+ export interface LoggerOptions {
27
+ /** Product ID (UUID) - required */
28
+ productId: string;
29
+ /** Default user ID applied to all logs (optional, overridden per-call) */
30
+ userId?: string;
31
+ /** Default session ID applied to all logs (optional, overridden per-call) */
32
+ sessionId?: string;
33
+ /** Custom endpoint URL (optional, for self-hosted or testing) */
34
+ endpoint?: string;
35
+ /** Flush interval in ms (default: 5000) */
36
+ flushInterval?: number;
37
+ /** Max buffer size before auto-flush (default: 50) */
38
+ maxBufferSize?: number;
39
+ /** Error callback - called silently, never throws (optional) */
40
+ onError?: (error: Error) => void;
41
+ }
42
+ export interface LogContext {
43
+ /** Override user ID for this log entry */
44
+ userId?: string;
45
+ /** Override session ID for this log entry */
46
+ sessionId?: string;
47
+ /** Structured metadata for the event */
48
+ metadata?: Record<string, unknown>;
49
+ }
50
+ export interface Logger {
51
+ /** Log an event. Non-blocking, never throws. */
52
+ log: (logType: string, message: string, context?: LogContext) => void;
53
+ /** Flush buffered logs to the server. */
54
+ flush: () => Promise<void>;
55
+ /** Create a child logger with additional default context. */
56
+ withContext: (context: Partial<Pick<LoggerOptions, 'userId' | 'sessionId'>>) => Logger;
57
+ /** Stop the flush timer and release resources. */
58
+ dispose: () => void;
59
+ }
60
+ export declare class LogError extends Error {
61
+ readonly statusCode: number;
62
+ constructor(message: string, statusCode: number);
63
+ }
package/dist/types.js ADDED
@@ -0,0 +1,8 @@
1
+ export class LogError extends Error {
2
+ statusCode;
3
+ constructor(message, statusCode) {
4
+ super(message);
5
+ this.statusCode = statusCode;
6
+ this.name = 'LogError';
7
+ }
8
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "edsger-logs",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "dev": "tsc --watch",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "keywords": [
22
+ "edsger",
23
+ "logs",
24
+ "product-logs",
25
+ "analytics"
26
+ ],
27
+ "author": "",
28
+ "license": "ISC",
29
+ "description": "Send product logs to Edsger for user behavior analytics",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/stevenzg/edsger.git",
33
+ "directory": "packages/logs"
34
+ },
35
+ "engines": {
36
+ "node": ">=18.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^20.0.0",
40
+ "typescript": "^5.0.0"
41
+ }
42
+ }