Skip to content

Commit 8e06f46

Browse files
TBonninbodinsamuel
andauthored
feat: billing with Lago (#3927)
## Changes - Send usage events to Lago I kept the original implementation from Thomas that allows multiple clients, which can be handy if we ever want to do some integration tests. - Only send metrics for paying accounts Lago does not reject the events, but we just we to start small ## 🧪 Tests - Go to Lago - Create a customer and assign a plan with the external id = account_id - Modify .env ``` LAGO_API_KEY=SHH FLAG_USAGE_ENABLED=true ``` - Trigger an action - ?? - Profit --------- Co-authored-by: Samuel Bodin <[email protected]>
1 parent be47eed commit 8e06f46

File tree

25 files changed

+286
-29
lines changed

25 files changed

+286
-29
lines changed

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ COPY packages/webhooks/package.json ./packages/webhooks/package.json
4040
COPY packages/fleet/package.json ./packages/fleet/package.json
4141
COPY packages/providers/package.json ./packages/providers/package.json
4242
COPY packages/runner-sdk/package.json ./packages/runner-sdk/package.json
43+
COPY packages/billing/package.json ./packages/billing/package.json
4344
COPY package*.json ./
4445

4546
# Install every dependencies

package-lock.json

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/billing/lib/billing.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { uuidv7 } from 'uuidv7';
2+
3+
import { Err, Ok, flagHasUsage, networkError, report, retryFlexible } from '@nangohq/utils';
4+
5+
import type { BillingClient, BillingIngestEvent, BillingMetric } from './types.js';
6+
import type { Result } from '@nangohq/utils';
7+
8+
export class Billing {
9+
constructor(private client: BillingClient) {
10+
this.client = client;
11+
}
12+
13+
async send(type: BillingMetric['type'], value: number, props: BillingMetric['properties']): Promise<Result<void>> {
14+
return this.sendAll([{ type, value, properties: props }]);
15+
}
16+
17+
async sendAll(events: BillingMetric[]): Promise<Result<void>> {
18+
const mapped = events.flatMap((event) => {
19+
if (event.value === 0) {
20+
return [];
21+
}
22+
23+
return [
24+
{
25+
type: event.type,
26+
accountId: event.properties.accountId,
27+
idempotencyKey: event.properties.idempotencyKey || uuidv7(),
28+
timestamp: event.properties.timestamp || new Date(),
29+
properties: {
30+
count: event.value
31+
}
32+
}
33+
];
34+
});
35+
36+
return this.ingest(mapped);
37+
}
38+
39+
// Note: Events are sent immediately
40+
private async ingest(events: BillingIngestEvent[]): Promise<Result<void>> {
41+
if (!flagHasUsage) {
42+
return Ok(undefined);
43+
}
44+
45+
try {
46+
await retryFlexible(
47+
async () => {
48+
await this.client.ingest(events);
49+
},
50+
{
51+
max: 3,
52+
onError: ({ err }) => {
53+
if (
54+
err instanceof TypeError &&
55+
err.cause &&
56+
typeof err.cause === 'object' &&
57+
'code' in err.cause &&
58+
networkError.includes(err.cause.code as string)
59+
) {
60+
return { retry: true, reason: 'maybe_unreachable' };
61+
}
62+
if (err instanceof Response && err.status >= 500) {
63+
return { retry: true, reason: 'status_code' };
64+
}
65+
return { retry: false, reason: 'unknown' };
66+
}
67+
}
68+
);
69+
return Ok(undefined);
70+
} catch (err: unknown) {
71+
const e = new Error(`Failed to send billing event`, { cause: err });
72+
report(e);
73+
return Err(e);
74+
}
75+
}
76+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Client as LagoClient } from 'lago-javascript-client';
2+
3+
import { envs } from '../envs.js';
4+
5+
import type { BillingClient, BillingIngestEvent } from '../types.js';
6+
import type { EventInput } from 'lago-javascript-client';
7+
8+
const lagoClient = LagoClient(envs.LAGO_API_KEY || '');
9+
10+
export const lago: BillingClient = {
11+
ingest: async (events: BillingIngestEvent[]): Promise<void> => {
12+
const batchSize = 100;
13+
for (let i = 0; i < events.length; i += batchSize) {
14+
// Any fail will bubble up on purpose
15+
// getLagoError is useless and modifying the error
16+
await lagoClient.events.createBatchEvents({
17+
events: events.slice(i, i + batchSize).map(toLagoEvent)
18+
});
19+
}
20+
}
21+
};
22+
23+
function toLagoEvent(event: BillingIngestEvent): EventInput['event'] {
24+
return {
25+
code: event.type,
26+
transaction_id: event.idempotencyKey,
27+
external_subscription_id: event.accountId.toString(),
28+
timestamp: event.timestamp.getTime(),
29+
properties: event.properties
30+
};
31+
}

packages/billing/lib/envs.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { ENVS, parseEnvs } from '@nangohq/utils';
2+
3+
export const envs = parseEnvs(ENVS);

packages/billing/lib/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { Billing } from './billing.js';
2+
import { lago } from './clients/lago.js';
3+
4+
export type { BillingIngestEvent, BillingMetric } from './types.js';
5+
6+
export const billing = new Billing(lago);

packages/billing/lib/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export interface BillingClient {
2+
ingest: (events: BillingIngestEvent[]) => Promise<void>;
3+
}
4+
5+
export interface BillingIngestEvent {
6+
type: 'monthly_active_records' | 'billable_connections' | 'billable_actions';
7+
idempotencyKey: string;
8+
accountId: number;
9+
timestamp: Date;
10+
properties: Record<string, string | number>;
11+
}
12+
13+
export interface BillingMetric {
14+
type: BillingIngestEvent['type'];
15+
value: number;
16+
properties: { accountId: number; timestamp?: Date | undefined; idempotencyKey?: string | undefined };
17+
}

packages/billing/package.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "@nangohq/billing",
3+
"version": "1.0.0",
4+
"type": "module",
5+
"main": "./dist/index.js",
6+
"types": "./dist/index.js",
7+
"private": true,
8+
"scripts": {},
9+
"repository": {
10+
"type": "git",
11+
"url": "git+https://github.com/NangoHQ/nango.git",
12+
"directory": "packages/billing"
13+
},
14+
"dependencies": {
15+
"@nangohq/utils": "file:../utils",
16+
"lago-javascript-client": "1.22.0",
17+
"uuidv7": "1.0.2"
18+
},
19+
"devDependencies": {},
20+
"files": [
21+
"dist/**/*"
22+
]
23+
}

packages/billing/tsconfig.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"rootDir": "lib",
5+
"outDir": "dist"
6+
},
7+
"references": [
8+
{
9+
"path": "../utils"
10+
}
11+
],
12+
"include": ["lib/**/*", "../utils/lib/vitest.d.ts"]
13+
}

packages/kvstore/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"repository": {
1212
"type": "git",
1313
"url": "git+https://github.com/NangoHQ/nango.git",
14-
"directory": "packages/logs"
14+
"directory": "packages/kvstore"
1515
},
1616
"dependencies": {
1717
"@nangohq/utils": "file:../utils",

0 commit comments

Comments
 (0)