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
5 changes: 5 additions & 0 deletions .changeset/bright-events-track.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@logdash/node': minor
---

feat: add `track` that sends events, which show up as goals in the domain's Analytics
5 changes: 5 additions & 0 deletions .changeset/quiet-timers-exit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@logdash/node': patch
---

fix: let the process exit after `flush()`, since the background flush timer no longer keeps it alive
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,25 @@ logdash.setMetric('users', 0);
logdash.mutateMetric('users', 1);
```

## Events

```typescript
import { Logdash } from '@logdash/node';

const logdash = new Logdash('<your-api-key>');

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();
```

Expand Down
98 changes: 85 additions & 13 deletions src/Logdash.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -17,6 +24,7 @@ export interface LogdashOptions {
interface LogdashCore {
logQueue: RequestQueue<LogPayload> | null;
metricQueue: RequestQueue<MetricPayload> | null;
eventQueue: RequestQueue<EventPayload> | null;
sequenceNumber: number;
verbose: boolean;
app?: string;
Expand Down Expand Up @@ -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
Expand All @@ -68,6 +81,7 @@ export class Logdash {
this.core = {
logQueue: null,
metricQueue: null,
eventQueue: null,
sequenceNumber: 0,
verbose,
};
Expand Down Expand Up @@ -153,6 +167,58 @@ export class Logdash {
});
}

// === Event Methods ===

track(
name: string,
props?: Record<string, string | number | boolean>,
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 {
Expand All @@ -162,19 +228,17 @@ export class Logdash {
// === Lifecycle ===

async flush(): Promise<void> {
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 ===
Expand All @@ -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({
Expand All @@ -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);
}

Expand Down
30 changes: 30 additions & 0 deletions src/__tests__/HttpTransport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading