diff --git a/.changeset/bright-events-track.md b/.changeset/bright-events-track.md new file mode 100644 index 0000000..8bf9065 --- /dev/null +++ b/.changeset/bright-events-track.md @@ -0,0 +1,5 @@ +--- +'@logdash/node': minor +--- + +feat: add `track` that sends events, which show up as goals in the domain's Analytics diff --git a/.changeset/quiet-timers-exit.md b/.changeset/quiet-timers-exit.md new file mode 100644 index 0000000..7fe0880 --- /dev/null +++ b/.changeset/quiet-timers-exit.md @@ -0,0 +1,5 @@ +--- +'@logdash/node': patch +--- + +fix: let the process exit after `flush()`, since the background flush timer no longer keeps it alive diff --git a/README.md b/README.md index 996e45f..f079f98 100644 --- a/README.md +++ b/README.md @@ -64,10 +64,25 @@ logdash.setMetric('users', 0); logdash.mutateMetric('users', 1); ``` +## Events + +```typescript +import { Logdash } from '@logdash/node'; + +const logdash = new Logdash(''); + +logdash.track('agent_run', { model: 'opus', tools: 3 }, { userId: user.id }); +``` + +Events show up in your domain's Analytics as goals, with a daily trend. +For signed-in users, pass their internal ID as `userId`, so one person counts once across your website and apps. +Without it, Logdash counts a visitor of that app that changes every day. +Never put personal data in props. + ## Graceful Shutdown ```typescript -// Ensure all logs and metrics are sent before exiting +// Ensure all logs, metrics and events are sent before exiting await logdash.flush(); ``` diff --git a/src/Logdash.ts b/src/Logdash.ts index b38206e..378e10d 100644 --- a/src/Logdash.ts +++ b/src/Logdash.ts @@ -1,13 +1,20 @@ +import { randomUUID } from 'node:crypto'; import chalk from 'chalk'; import { internalLogger } from './logger/internalLogger.js'; import { RequestQueue } from './queue/RequestQueue.js'; import { + EventPayload, HttpTransport, LogPayload, MetricPayload, } from './transport/HttpTransport.js'; import { LogLevel, LOG_LEVEL_COLORS } from './types/LogLevel.js'; +const EVENT_NAME = /^[a-z][a-z0-9_]{0,63}$/; +// The website tracker sends these, so the API rejects them from apps +const WEBSITE_EVENTS = ['pageview', 'pageleave', 'browser_error']; +const EVENT_COLOR: [number, number, number] = [152, 16, 250]; + export interface LogdashOptions { host?: string; verbose?: boolean; @@ -17,6 +24,7 @@ export interface LogdashOptions { interface LogdashCore { logQueue: RequestQueue | null; metricQueue: RequestQueue | null; + eventQueue: RequestQueue | null; sequenceNumber: number; verbose: boolean; app?: string; @@ -57,6 +65,11 @@ export class Logdash { (metrics) => transport.sendMetrics(metrics), { batchSize: 1 }, ), + // Events too, so a retry re-sends only the event that failed + eventQueue: new RequestQueue( + (events) => transport.sendEvents(events), + { batchSize: 1 }, + ), sequenceNumber: 0, verbose, // An empty app means no app: leave it out of payloads @@ -68,6 +81,7 @@ export class Logdash { this.core = { logQueue: null, metricQueue: null, + eventQueue: null, sequenceNumber: 0, verbose, }; @@ -153,6 +167,58 @@ export class Logdash { }); } + // === Event Methods === + + track( + name: string, + props?: Record, + options?: { userId?: string | number }, + ): void { + // typeof first: RegExp.test turns undefined into 'undefined', a valid name + if ( + typeof name !== 'string' || + !EVENT_NAME.test(name) || + WEBSITE_EVENTS.includes(name) + ) { + internalLogger.warn( + `Event "${name}" was not sent: its name must match ${EVENT_NAME} and not be one of ${WEBSITE_EVENTS.join(', ')}.`, + ); + return; + } + + const userId = options?.userId; + if (typeof userId === 'string' && userId.includes('@')) { + internalLogger.warn( + `Event "${name}" was not sent: userId must be an internal ID, never an email address.`, + ); + return; + } + + if (!this.core.eventQueue) { + // Local mode: print the event like a log, send nothing + this.printToConsole( + 'EVENT', + EVENT_COLOR, + this.formatData(props ? [name, props] : [name]), + new Date(), + ); + return; + } + + if (this.core.verbose) { + internalLogger.verbose(`Tracking event ${name}`); + } + + this.core.eventQueue.add({ + // Retries re-send this same id, so the API stores the event once + id: randomUUID(), + name, + props, + userId, + app: this.core.app, + }); + } + // === Namespace === withNamespace(name: string): Logdash { @@ -162,19 +228,17 @@ export class Logdash { // === Lifecycle === async flush(): Promise { - if (!this.core.logQueue || !this.core.metricQueue) { - return; // Local mode: nothing to flush - } - await Promise.all([ - this.core.logQueue.flush(), - this.core.metricQueue.flush(), + this.core.logQueue?.flush(), + this.core.metricQueue?.flush(), + this.core.eventQueue?.flush(), ]); } destroy(): void { this.core.logQueue?.destroy(); this.core.metricQueue?.destroy(); + this.core.eventQueue?.destroy(); } // === Private Methods === @@ -184,7 +248,12 @@ export class Logdash { const now = new Date(); // Print to console with colors - this.printToConsole(level, message, now); + this.printToConsole( + level.toUpperCase(), + LOG_LEVEL_COLORS[level], + message, + now, + ); // Queue for sending (only in remote mode) this.core.logQueue?.add({ @@ -197,20 +266,23 @@ export class Logdash { }); } - private printToConsole(level: LogLevel, message: string, date: Date): void { - const color = LOG_LEVEL_COLORS[level]; - + private printToConsole( + label: string, + color: [number, number, number], + message: string, + date: Date, + ): void { const datePrefix = chalk.rgb(156, 156, 156)(`[${date.toISOString()}]`); - const levelPrefix = chalk.rgb( + const labelPrefix = chalk.rgb( color[0], color[1], color[2], - )(`${level.toUpperCase()} `); + )(`${label} `); const namespacePrefix = this.namespace ? chalk.rgb(180, 180, 180)(`[${this.namespace}] `) : ''; - const formattedMessage = `${datePrefix} ${levelPrefix}${namespacePrefix}${message}`; + const formattedMessage = `${datePrefix} ${labelPrefix}${namespacePrefix}${message}`; console.log(formattedMessage); } diff --git a/src/__tests__/HttpTransport.test.ts b/src/__tests__/HttpTransport.test.ts index 512ec71..51117fd 100644 --- a/src/__tests__/HttpTransport.test.ts +++ b/src/__tests__/HttpTransport.test.ts @@ -132,6 +132,36 @@ describe('HttpTransport', () => { }); }); + describe('sendEvents', () => { + it('posts the event to /events endpoint', async () => { + const transport = new HttpTransport({ + host: 'https://api.test.com', + apiKey: 'test-key', + }); + const event = { + id: '9b2f6c1e-4d3a-4e8b-a5c7-1f2e3d4c5b6a', + name: 'agent_run', + props: { model: 'opus', tools: 3, cached: false }, + userId: 42, + app: 'desktop', + }; + + await transport.sendEvents([event]); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.test.com/events', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'project-api-key': 'test-key', + }, + }), + ); + expect(JSON.parse(mockFetch.mock.calls[0][1].body)).toEqual(event); + }); + }); + describe('error handling', () => { it('throws on non-ok response', async () => { mockFetch.mockResolvedValueOnce({ diff --git a/src/__tests__/Logdash.test.ts b/src/__tests__/Logdash.test.ts index b99392c..62bba62 100644 --- a/src/__tests__/Logdash.test.ts +++ b/src/__tests__/Logdash.test.ts @@ -4,6 +4,12 @@ import { Logdash } from '../Logdash.js'; describe('Logdash', () => { let mockFetch: ReturnType; + // Returns the bodies of the requests sent to path + const bodies = (path: string) => + mockFetch.mock.calls + .filter(([url]) => url.endsWith(path)) + .map(([, init]) => JSON.parse(init.body)); + beforeEach(() => { vi.spyOn(console, 'log').mockImplementation(() => {}); mockFetch = vi.fn().mockResolvedValue({ @@ -209,30 +215,28 @@ describe('Logdash', () => { }); describe('app', () => { - // Sends a log and a metric from the root and from a namespaced - // instance, and returns the request bodies they produced + // Sends a log, a metric and an event from the root and from a + // namespaced instance, and returns the request bodies they produced async function send(logdash: Logdash) { const auth = logdash.withNamespace('auth'); logdash.info('root log'); logdash.setMetric('users', 1); + logdash.track('app_opened'); auth.info('auth log'); auth.mutateMetric('logins', 1); + auth.track('signup_completed'); await logdash.flush(); logdash.destroy(); - const bodies = (path: string) => - mockFetch.mock.calls - .filter(([url]) => url.endsWith(path)) - .map(([, init]) => JSON.parse(init.body)); - return { logs: bodies('/logs/batch').flatMap((body) => body.logs), metrics: bodies('/metrics'), + events: bodies('/events'), }; } - it('adds app to every log and metric', async () => { - const { logs, metrics } = await send( + it('adds app to every log, metric and event', async () => { + const { logs, metrics, events } = await send( new Logdash('test-key', { app: 'desktop' }), ); @@ -257,21 +261,157 @@ describe('Logdash', () => { app: 'desktop', }, ]); + expect(events).toEqual([ + { id: expect.any(String), name: 'app_opened', app: 'desktop' }, + { + id: expect.any(String), + name: 'signup_completed', + app: 'desktop', + }, + ]); }); it.each([undefined, ''])( - 'leaves app out of every log and metric when app is %j', + 'leaves app out of every log, metric and event when app is %j', async (app) => { - const { logs, metrics } = await send( + const { logs, metrics, events } = await send( new Logdash('test-key', { app }), ); expect(logs).toHaveLength(2); expect(metrics).toHaveLength(2); - for (const payload of [...logs, ...metrics]) { + expect(events).toHaveLength(2); + for (const payload of [...logs, ...metrics, ...events]) { expect(payload).not.toHaveProperty('app'); } }, ); }); + + describe('track', () => { + const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + + afterEach(() => { + vi.useRealTimers(); + }); + + it('posts each event with its props, userId and own UUID v4 id', async () => { + const logdash = new Logdash('test-key'); + logdash.track( + 'agent_run', + { model: 'opus', tools: 3, cached: false }, + { userId: 42 }, + ); + logdash.track('agent_run', undefined, { userId: 'user_7' }); + await logdash.flush(); + logdash.destroy(); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.logdash.io/events', + expect.objectContaining({ method: 'POST' }), + ); + const [first, second] = bodies('/events'); + expect(first).toEqual({ + id: expect.stringMatching(UUID_V4), + name: 'agent_run', + props: { model: 'opus', tools: 3, cached: false }, + userId: 42, + }); + expect(second).toEqual({ + id: expect.stringMatching(UUID_V4), + name: 'agent_run', + userId: 'user_7', + }); + expect(second.id).not.toBe(first.id); + }); + + it.each(['Agent Run', '1st_run', 'pageview', undefined])( + 'drops an event named %j with a warning', + async (name) => { + const logdash = new Logdash('test-key'); + logdash.track(name); + await logdash.flush(); + logdash.destroy(); + + expect(bodies('/events')).toEqual([]); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining(`Event "${name}" was not sent`), + ); + }, + ); + + it('drops an event whose userId is an email address with a warning', async () => { + const logdash = new Logdash('test-key'); + logdash.track('agent_run', undefined, { + userId: 'alice@example.com', + }); + await logdash.flush(); + logdash.destroy(); + + expect(bodies('/events')).toEqual([]); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('userId must be an internal ID'), + ); + }); + + it('re-sends the same id when it retries', async () => { + vi.useFakeTimers(); + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Server Error', + }); + const logdash = new Logdash('test-key'); + logdash.track('agent_run'); + + const flushed = logdash.flush(); + await vi.advanceTimersByTimeAsync(10_000); + await flushed; + logdash.destroy(); + + const sent = bodies('/events'); + expect(sent).toHaveLength(2); + expect(sent[1].id).toBe(sent[0].id); + }); + + it('flush waits for pending events', async () => { + let respond: (response: object) => void; + mockFetch.mockReturnValueOnce( + new Promise((resolve) => { + respond = resolve; + }), + ); + const logdash = new Logdash('test-key'); + logdash.track('agent_run'); + + let flushed = false; + const flushing = logdash.flush().then(() => { + flushed = true; + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(flushed).toBe(false); + + respond!({ ok: true, status: 202, statusText: 'Accepted' }); + await flushing; + logdash.destroy(); + expect(flushed).toBe(true); + }); + + it('prints the event like a log and sends nothing without an API key', async () => { + const logdash = new Logdash(); + logdash + .withNamespace('agents') + .track('agent_run', { model: 'opus' }); + await logdash.flush(); + logdash.destroy(); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenLastCalledWith( + expect.stringMatching( + /EVENT .*\[agents\] .*agent_run \{"model":"opus"\}/, + ), + ); + }); + }); }); diff --git a/src/__tests__/RequestQueue.test.ts b/src/__tests__/RequestQueue.test.ts index 0712e73..bfd021e 100644 --- a/src/__tests__/RequestQueue.test.ts +++ b/src/__tests__/RequestQueue.test.ts @@ -163,6 +163,16 @@ describe('RequestQueue', () => { }); }); + describe('flush timer', () => { + it('does not keep the process alive', () => { + const queue = new RequestQueue(vi.fn()); + + expect(queue['flushTimer'].hasRef()).toBe(false); + + queue.destroy(); + }); + }); + describe('destroy', () => { it('stops the flush timer', async () => { const sendFn = vi.fn().mockResolvedValue(undefined); diff --git a/src/queue/RequestQueue.ts b/src/queue/RequestQueue.ts index 75359f0..d490711 100644 --- a/src/queue/RequestQueue.ts +++ b/src/queue/RequestQueue.ts @@ -72,6 +72,8 @@ export class RequestQueue { } this.scheduleFlush(); }, this.options.flushIntervalMs); + // The timer alone must not keep the process alive: flush() sends the rest + this.flushTimer.unref?.(); } private flushBatch(): void { diff --git a/src/transport/HttpTransport.ts b/src/transport/HttpTransport.ts index 240b2ed..4ee8100 100644 --- a/src/transport/HttpTransport.ts +++ b/src/transport/HttpTransport.ts @@ -28,6 +28,13 @@ export class HttpTransport { ); } + async sendEvents(events: EventPayload[]): Promise { + // The API takes one event per request + await Promise.all( + events.map((event) => this.request('POST', '/events', event)), + ); + } + private async request( method: 'POST' | 'PUT', path: string, @@ -74,3 +81,11 @@ export interface MetricPayload { namespace?: string; app?: string; } + +export interface EventPayload { + id: string; + name: string; + props?: Record; + userId?: string | number; + app?: string; +}