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/calm-metrics-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@logdash/node': patch
---

fix: retry only the metric that failed, so a failed request no longer re-applies `mutateMetric` changes that already went through
18 changes: 9 additions & 9 deletions .changeset/config.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.5/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
"$schema": "https://unpkg.com/@changesets/config@3.0.5/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
5 changes: 5 additions & 0 deletions .changeset/tidy-apps-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@logdash/node': minor
---

feat: add `app` option that names the app sending each log and metric
20 changes: 10 additions & 10 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"semi": true,
"proseWrap": "preserve",
"endOfLine": "lf",
"tabWidth": 4,
"arrowParens": "always",
"htmlWhitespaceSensitivity": "ignore"
"useTabs": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"semi": true,
"proseWrap": "preserve",
"endOfLine": "lf",
"tabWidth": 4,
"arrowParens": "always",
"htmlWhitespaceSensitivity": "ignore"
}
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,24 @@ authLogdash.info('User logged in');
authLogdash.error('Authentication failed');
```

## Apps

A domain has one domain key, shared by all of its apps, for example a desktop app, an API and a landing page.
Set `app` to name this app, so the dashboard can filter logs and metrics by app.
Logdash creates the app when its first log or metric arrives.

```typescript
const logdash = new Logdash('<your-domain-key>', { app: 'desktop' });

logdash.info('Window opened');

// Namespaced instances keep the app
const sidebarLogdash = logdash.withNamespace('sidebar');
sidebarLogdash.info('Sidebar collapsed');
```

Older service keys ignore `app`.

## Metrics

```typescript
Expand Down Expand Up @@ -70,6 +88,7 @@ new Logdash(apiKey?, options?)
| `apiKey` | no | - | Api key used to authorize against logdash servers. If you don't provide one, logs will be logged into local console only |
| `options.host` | no | `https://api.logdash.io` | Custom API host, useful with self-hosted instances |
| `options.verbose` | no | `false` | Useful for debugging purposes |
| `options.app` | no | - | Name of the app that sends the logs and metrics, for example `desktop` or `api`. See [Apps](#apps) |

## License

Expand Down
96 changes: 48 additions & 48 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,50 +1,50 @@
{
"name": "@logdash/node",
"version": "1.0.2",
"private": false,
"description": "How solo founders keep their SaaS apps healthy.",
"scripts": {
"build": "tsc -p tsconfig.lib.json",
"test": "vitest run",
"test:watch": "vitest",
"release": "pnpm build && changeset publish",
"changeset": "changeset",
"version-packages": "changeset version"
},
"author": "Logdash Team <logdash.contact@gmail.com>",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/logdash-io/node-sdk.git"
},
"homepage": "https://github.com/logdash-io/node-sdk",
"devDependencies": {
"@changesets/cli": "^2.29.8",
"@swc/core": "^1.15.6",
"@types/node": "^25.0.3",
"prettier": "^3.7.4",
"typescript": "5.9.3",
"vitest": "^4.0.16"
},
"bugs": {
"url": "https://github.com/logdash-io/node-sdk/issues"
},
"packageManager": "pnpm@9.0.0",
"engines": {
"node": ">=18"
},
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"keywords": [
"observability",
"monitoring",
"logging",
"tracing",
"metrics"
],
"dependencies": {
"chalk": "^5.6.2"
}
"name": "@logdash/node",
"version": "1.0.2",
"private": false,
"description": "How solo founders keep their SaaS apps healthy.",
"scripts": {
"build": "tsc -p tsconfig.lib.json",
"test": "vitest run",
"test:watch": "vitest",
"release": "pnpm build && changeset publish",
"changeset": "changeset",
"version-packages": "changeset version"
},
"author": "Logdash Team <logdash.contact@gmail.com>",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/logdash-io/node-sdk.git"
},
"homepage": "https://github.com/logdash-io/node-sdk",
"devDependencies": {
"@changesets/cli": "^2.29.8",
"@swc/core": "^1.15.6",
"@types/node": "^25.0.3",
"prettier": "^3.7.4",
"typescript": "5.9.3",
"vitest": "^4.0.16"
},
"bugs": {
"url": "https://github.com/logdash-io/node-sdk/issues"
},
"packageManager": "pnpm@9.0.0",
"engines": {
"node": ">=18"
},
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"keywords": [
"observability",
"monitoring",
"logging",
"tracing",
"metrics"
],
"dependencies": {
"chalk": "^5.6.2"
}
}
14 changes: 12 additions & 2 deletions src/Logdash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import { LogLevel, LOG_LEVEL_COLORS } from './types/LogLevel.js';
export interface LogdashOptions {
host?: string;
verbose?: boolean;
app?: string;
}

interface LogdashCore {
logQueue: RequestQueue<LogPayload> | null;
metricQueue: RequestQueue<MetricPayload> | null;
sequenceNumber: number;
verbose: boolean;
app?: string;
}

export class Logdash {
Expand Down Expand Up @@ -49,11 +51,16 @@ export class Logdash {
logQueue: new RequestQueue((logs) =>
transport.sendLogs(logs),
),
metricQueue: new RequestQueue((metrics) =>
transport.sendMetrics(metrics),
// Each metric is its own request, so it is its own batch:
// a retry must not re-apply metrics that already went through
metricQueue: new RequestQueue(
(metrics) => transport.sendMetrics(metrics),
{ batchSize: 1 },
),
sequenceNumber: 0,
verbose,
// An empty app means no app: leave it out of payloads
app: options?.app || undefined,
};
} else {
internalLogger.warn('No API key provided, using local mode.');
Expand Down Expand Up @@ -124,6 +131,7 @@ export class Logdash {
value,
operation: 'set',
namespace: this.namespace,
app: this.core.app,
});
}

Expand All @@ -141,6 +149,7 @@ export class Logdash {
value: delta,
operation: 'change',
namespace: this.namespace,
app: this.core.app,
});
}

Expand Down Expand Up @@ -184,6 +193,7 @@ export class Logdash {
createdAt: now.toISOString(),
sequenceNumber: this.core.sequenceNumber++,
namespace: this.namespace,
app: this.core.app,
});
}

Expand Down
105 changes: 105 additions & 0 deletions src/__tests__/Logdash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,21 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Logdash } from '../Logdash.js';

describe('Logdash', () => {
let mockFetch: ReturnType<typeof vi.fn>;

beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {});
mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
});
vi.stubGlobal('fetch', mockFetch);
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

describe('constructor', () => {
Expand Down Expand Up @@ -169,4 +178,100 @@ describe('Logdash', () => {
logdash.destroy();
});
});

describe('metric retries', () => {
afterEach(() => {
vi.useRealTimers();
});

it('re-sends only the metric that failed', async () => {
vi.useFakeTimers();
mockFetch.mockImplementation(async (_url, init) =>
JSON.parse(init.body).name === 'failing'
? { ok: false, status: 500, statusText: 'Server Error' }
: { ok: true, status: 200, statusText: 'OK' },
);
const logdash = new Logdash('test-key');
logdash.mutateMetric('requests', 1);
logdash.mutateMetric('failing', 1);

const flushed = logdash.flush();
await vi.advanceTimersByTimeAsync(10_000);
await flushed;
logdash.destroy();

const sent = mockFetch.mock.calls.map(
([, init]) => JSON.parse(init.body).name,
);
expect(sent.filter((name) => name === 'requests')).toHaveLength(1);
expect(sent.filter((name) => name === 'failing')).toHaveLength(3);
});
});

describe('app', () => {
// Sends a log and a metric 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);
auth.info('auth log');
auth.mutateMetric('logins', 1);
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'),
};
}

it('adds app to every log and metric', async () => {
const { logs, metrics } = await send(
new Logdash('test-key', { app: 'desktop' }),
);

expect(logs).toEqual([
expect.objectContaining({
message: 'root log',
app: 'desktop',
}),
expect.objectContaining({
message: 'auth log',
namespace: 'auth',
app: 'desktop',
}),
]);
expect(metrics).toEqual([
{ name: 'users', value: 1, operation: 'set', app: 'desktop' },
{
name: 'logins',
value: 1,
operation: 'change',
namespace: 'auth',
app: 'desktop',
},
]);
});

it.each([undefined, ''])(
'leaves app out of every log and metric when app is %j',
async (app) => {
const { logs, metrics } = await send(
new Logdash('test-key', { app }),
);

expect(logs).toHaveLength(2);
expect(metrics).toHaveLength(2);
for (const payload of [...logs, ...metrics]) {
expect(payload).not.toHaveProperty('app');
}
},
);
});
});
2 changes: 2 additions & 0 deletions src/transport/HttpTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,13 @@ export interface LogPayload {
createdAt: string;
sequenceNumber: number;
namespace?: string;
app?: string;
}

export interface MetricPayload {
name: string;
value: number;
operation: 'set' | 'change';
namespace?: string;
app?: string;
}
Loading
Loading