diff --git a/.changeset/calm-metrics-retry.md b/.changeset/calm-metrics-retry.md new file mode 100644 index 0000000..402685a --- /dev/null +++ b/.changeset/calm-metrics-retry.md @@ -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 diff --git a/.changeset/config.json b/.changeset/config.json index d5e9940..9bdce4c 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -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": [] } diff --git a/.changeset/tidy-apps-wave.md b/.changeset/tidy-apps-wave.md new file mode 100644 index 0000000..8c6117d --- /dev/null +++ b/.changeset/tidy-apps-wave.md @@ -0,0 +1,5 @@ +--- +'@logdash/node': minor +--- + +feat: add `app` option that names the app sending each log and metric diff --git a/.prettierrc b/.prettierrc index ed1c0bc..1ba6ef0 100644 --- a/.prettierrc +++ b/.prettierrc @@ -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" } diff --git a/README.md b/README.md index 5f4a1ed..996e45f 100644 --- a/README.md +++ b/README.md @@ -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('', { 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 @@ -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 diff --git a/package.json b/package.json index 6ff47ef..a6a3109 100644 --- a/package.json +++ b/package.json @@ -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 ", - "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 ", + "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" + } } diff --git a/src/Logdash.ts b/src/Logdash.ts index bd5ae4d..b38206e 100644 --- a/src/Logdash.ts +++ b/src/Logdash.ts @@ -11,6 +11,7 @@ import { LogLevel, LOG_LEVEL_COLORS } from './types/LogLevel.js'; export interface LogdashOptions { host?: string; verbose?: boolean; + app?: string; } interface LogdashCore { @@ -18,6 +19,7 @@ interface LogdashCore { metricQueue: RequestQueue | null; sequenceNumber: number; verbose: boolean; + app?: string; } export class Logdash { @@ -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.'); @@ -124,6 +131,7 @@ export class Logdash { value, operation: 'set', namespace: this.namespace, + app: this.core.app, }); } @@ -141,6 +149,7 @@ export class Logdash { value: delta, operation: 'change', namespace: this.namespace, + app: this.core.app, }); } @@ -184,6 +193,7 @@ export class Logdash { createdAt: now.toISOString(), sequenceNumber: this.core.sequenceNumber++, namespace: this.namespace, + app: this.core.app, }); } diff --git a/src/__tests__/Logdash.test.ts b/src/__tests__/Logdash.test.ts index d972d37..b99392c 100644 --- a/src/__tests__/Logdash.test.ts +++ b/src/__tests__/Logdash.test.ts @@ -2,12 +2,21 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Logdash } from '../Logdash.js'; describe('Logdash', () => { + let mockFetch: ReturnType; + 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', () => { @@ -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'); + } + }, + ); + }); }); diff --git a/src/transport/HttpTransport.ts b/src/transport/HttpTransport.ts index aab2217..240b2ed 100644 --- a/src/transport/HttpTransport.ts +++ b/src/transport/HttpTransport.ts @@ -64,6 +64,7 @@ export interface LogPayload { createdAt: string; sequenceNumber: number; namespace?: string; + app?: string; } export interface MetricPayload { @@ -71,4 +72,5 @@ export interface MetricPayload { value: number; operation: 'set' | 'change'; namespace?: string; + app?: string; } diff --git a/tsconfig.json b/tsconfig.json index 0b40889..964fe0f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,20 +1,20 @@ { - "files": [], - "include": [], - "references": [ - { - "path": "./tsconfig.lib.json" - } - ], - "compilerOptions": { - "rootDir": ".", - "forceConsistentCasingInFileNames": true, - "strict": false, - "noImplicitOverride": true, - "noPropertyAccessFromIndexSignature": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "compilerOptions": { + "rootDir": ".", + "forceConsistentCasingInFileNames": true, + "strict": false, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + } } diff --git a/tsconfig.lib.json b/tsconfig.lib.json index 2d9d1e7..39e9a9a 100644 --- a/tsconfig.lib.json +++ b/tsconfig.lib.json @@ -1,29 +1,24 @@ { - "extends": "./tsconfig.json", - "compilerOptions": { - "esModuleInterop": true, - "skipLibCheck": true, - "target": "ES2020", - "allowJs": true, - "resolveJsonModule": true, - "moduleDetection": "force", - "isolatedModules": true, - "strict": false, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "sourceMap": true, - "declaration": true, - "declarationMap": true - }, - "exclude": [ - "src/**/*.spec.ts", - "node_modules/**/*" - ], - "include": [ - "src/**/*.ts" - ] + "extends": "./tsconfig.json", + "compilerOptions": { + "esModuleInterop": true, + "skipLibCheck": true, + "target": "ES2020", + "allowJs": true, + "resolveJsonModule": true, + "moduleDetection": "force", + "isolatedModules": true, + "strict": false, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "sourceMap": true, + "declaration": true, + "declarationMap": true + }, + "exclude": ["src/**/*.spec.ts", "node_modules/**/*"], + "include": ["src/**/*.ts"] }