diff --git a/.prettierrc b/.prettierrc index 3dd9eed..f893f27 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,7 +1,6 @@ { - "arrowParens": "always", - "bracketSpacing": true, - "endOfLine": "lf", - "printWidth": 120, - "singleQuote": true - } \ No newline at end of file + "arrowParens": "always", + "bracketSpacing": true, + "endOfLine": "lf", + "printWidth": 120 +} diff --git a/package-lock.json b/package-lock.json index 7c0a8db..55b3dce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "cron": "^2.4.3", + "dotenv": "^16.4.5", "express": "^4.18.2", "loglevel": "^1.8.1", "prom-client": "^14.2.0", @@ -955,6 +956,17 @@ "node": ">=6.0.0" } }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", diff --git a/package.json b/package.json index bce86dc..7c2fcb8 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "scripts": { "build": "npx tsc", - "dev": "pwd && source ./.dev.env && concurrently \"npx tsc --watch\" \"nodemon -q dist/index.js\"", + "dev": "concurrently \"npx tsc --watch\" \"nodemon -q dist/index.js\"", "start": "node dist/index.js" }, "author": { @@ -18,6 +18,7 @@ "license": "MIT", "dependencies": { "cron": "^2.4.3", + "dotenv": "^16.4.5", "express": "^4.18.2", "loglevel": "^1.8.1", "prom-client": "^14.2.0", diff --git a/src/api.ts b/src/api.ts index 839e6ee..06cf149 100644 --- a/src/api.ts +++ b/src/api.ts @@ -10,6 +10,7 @@ export class YnabAPI { this.client = new API(this.accessToken); } + public async getAccountName(): Promise { return (await this.client.budgets.getBudgetById(this.budgetId)).data.budget.name; } @@ -17,7 +18,11 @@ export class YnabAPI { public async getCategoryBudgets(): Promise { const catGroups = (await this.client.categories.getCategories(this.budgetId)).data.category_groups; const categories = (catGroups.map(g => g.categories)).flat(1); - log.debug(`Got ${categories.length} categories`); + log.info(`Got ${categories.length} categories`); return categories; } + + public async getBudgetIds() { + return (await this.client.budgets.getBudgets().then(b => b.data.budgets)).map(b => this.budgetId); + } } diff --git a/src/collectors.ts b/src/collectors.ts index 0dd6a73..e4bea1b 100644 --- a/src/collectors.ts +++ b/src/collectors.ts @@ -1,80 +1,50 @@ import log from 'loglevel'; -import {Gauge, Registry} from "prom-client"; import {Account, Category} from "ynab"; +import * as metrics from './metrics'; export class YNABCollector { - accountBalances: Account[] = []; - categoryBalance: Category[] = []; - - - public async collectAccountBalanceMetrics(register: Registry) { - log.debug('Collecting account balance metrics..'); - - const accountLabels = ['account_name', 'type', 'closed']; - const accountClearedBalanceGauge = new Gauge({ - name: "ynab_cleared_account_balance", - registers: [register], - help: "Account Cleared Balance amounts", - labelNames: accountLabels, - collect: async () => { - log.debug(`Collecting Cleared Balance for ${this.accountBalances.length} accounts`); - this.accountBalances.forEach(a => { - accountClearedBalanceGauge.labels({account_name: a.name, type: a.type, closed: String(a.closed)}).set(a.cleared_balance / 1000); - }); - } - - }); - const accountUnClearedBalanceGauge = new Gauge({ - name: "ynab_uncleared_account_balance", - help: "Account Uncleared Balance amounts", - registers: [register], - labelNames: accountLabels, - collect: async () => { - log.debug(`Collecting Uncleared Balance for ${this.accountBalances.length} accounts`); - this.accountBalances.forEach(a => { - accountUnClearedBalanceGauge.labels({account_name: a.name, type: a.type, closed: String(a.closed)}).set(a.uncleared_balance / 1000); - }); - } - }); - } - - public convertMilliUnitsToUnits(amount: number): number { - return (amount / 1000); - } - - public async collectCategoryBalanceMetrics(register: Registry) { - log.debug('Collecting category balance metrics..'); - const self = this; // Store a reference to 'this', else typescript thinks this refers to the Gauge class, not the Collector class - - const catLabels = ['name', 'category_group_name', 'budgeted_amount', 'activity_amount', 'balance_amount', 'hidden', 'deleted']; - - // Define properties that differ between gauges - const gaugeProperties = [ - {name: 'budgeted', help: 'Category Budgeted amount', suffix: 'budgeted_amount'}, - {name: 'activity', help: 'Category Activity amount', suffix: 'activity_amount'}, - {name: 'balance', help: 'Category Balance amount', suffix: 'balance_amount'}, - {name: 'hidden', help: 'Category Balance amount', suffix: 'hidden'}, - {name: 'deleted', help: 'Category Balance amount', suffix: 'deleted'}, - ]; - - for (const prop of gaugeProperties) { - new Gauge({ - name: `ynab_category_${prop.suffix}`, - registers: [register], - help: prop.help, - labelNames: catLabels, - async collect() { - for (const cat of self.categoryBalance) { - this.labels({ - name: cat.name, - category_group_name: cat.category_group_name, - hidden: String(cat.hidden), - deleted: String(cat.deleted) - }).set(self.convertMilliUnitsToUnits(Number(cat[prop.name as keyof Category]) || 0)); - - } - } - }); - } + categoryBalance: Category[] = []; + + public collectAccountBalanceMetrics(budgetName: string, accountBalances: Account[]) { + accountBalances.forEach(a => { + metrics.ynab_cleared_account_balance.labels({account_name: a.name, budget_name: budgetName, type: a.type, closed: String(a.closed)}).set(a.cleared_balance / 1000); + metrics.ynab_uncleared_account_balance.labels({account_name: a.name, budget_name: budgetName, type: a.type, closed: String(a.closed)}).set(a.uncleared_balance / 1000); + }); + } + + public convertMilliUnitsToUnits(amount: number): number { + return (amount / 1000); + } + + public async collectCategoryBalanceMetrics(budgetName: string, categoryBalance: Category[]) { + for (const cat of categoryBalance) { + metrics.ynab_category_balance_amount.labels({ + name: cat.name, + category_group_name: cat.category_group_name, + hidden: String(cat.hidden), + deleted: String(cat.deleted), + budget_name: budgetName, + }).set( + this.convertMilliUnitsToUnits(cat.balance) + ); + metrics.ynab_category_budgeted_amount.labels({ + name: cat.name, + category_group_name: cat.category_group_name, + hidden: String(cat.hidden), + deleted: String(cat.deleted), + budget_name: budgetName, + }).set( + this.convertMilliUnitsToUnits(cat.budgeted) + ); + metrics.ynab_category_activity_amount.labels({ + name: cat.name, + category_group_name: cat.category_group_name, + hidden: String(cat.hidden), + deleted: String(cat.deleted), + budget_name: budgetName, + }).set( + this.convertMilliUnitsToUnits(cat.activity) + ); } + } } diff --git a/src/index.ts b/src/index.ts index 47cbb58..8e1d7ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,47 +2,41 @@ import {CronJob} from 'cron'; +import dotenv from "dotenv"; import express, {Express, Request, Response} from 'express'; -import {Registry} from "prom-client"; +import log, {LogLevelDesc} from 'loglevel'; +import 'source-map-support/register'; import {YnabAPI} from "./api"; import {YNABCollector} from "./collectors"; import {scheduledAccountBalanceUpdate, scheduledCategoryBalanceUpdate} from "./jobs/accounts"; -import log, {LogLevelDesc} from 'loglevel'; - -import 'source-map-support/register'; +import {registry} from './metrics'; async function main() { + dotenv.config(); const ynab = new YnabAPI(); - const register = new Registry(); const port = process.env.PORT || 9100; const app: Express = express(); const ynabCollector = new YNABCollector(); + const budgetName = await ynab.getAccountName(); new CronJob({ cronTime: "*/15 * * * *", onTick: async () => { - ynabCollector.accountBalances = (await scheduledAccountBalanceUpdate(ynab)).accounts; - log.info(`${ynabCollector.accountBalances.length} accounts refreshed`); - - ynabCollector.categoryBalance = (await scheduledCategoryBalanceUpdate(ynab)); - log.info(`${ynabCollector.categoryBalance.length} categories refreshed`); - + log.info(`Refreshing YNAB data at ${new Date().toLocaleString()}...`); + const accountBalance = await scheduledAccountBalanceUpdate(ynab); + const catBalance = await scheduledCategoryBalanceUpdate(ynab); + ynabCollector.collectAccountBalanceMetrics(budgetName, accountBalance.data.accounts); + ynabCollector.collectCategoryBalanceMetrics(budgetName, catBalance); }, start: true, runOnInit: true }); - register.setDefaultLabels({ - budget_name: await ynab.getAccountName() - }); - ynabCollector.collectAccountBalanceMetrics(register); - ynabCollector.collectCategoryBalanceMetrics(register); + app.get('/metrics', async (req: Request, res: Response) => { - res.setHeader('Content-Type', register.contentType); - log.debug('getting metrics'); - const results = await register.metrics(); - res.send(results); + res.setHeader('Content-Type', registry.contentType); + res.send(await registry.metrics()); }); app.listen(port, () => { diff --git a/src/jobs/accounts.ts b/src/jobs/accounts.ts index f1d253c..e71a844 100644 --- a/src/jobs/accounts.ts +++ b/src/jobs/accounts.ts @@ -1,16 +1,10 @@ -import {AccountsResponseData, Category} from "ynab"; +import {AccountsResponse, Category} from "ynab"; import {YnabAPI} from "../api"; -import log from 'loglevel'; -export async function scheduledAccountBalanceUpdate(ynab: YnabAPI): Promise { - log.info(`Starting scheduled account balance update at ${new Date().toLocaleString()} ...`); - const accounts = await ynab.client.accounts.getAccounts(ynab.budgetId); - log.info(`Fetched balances for ${accounts.data.accounts.length} accounts.`); - return accounts.data; -} +export async function scheduledAccountBalanceUpdate(ynab: YnabAPI): Promise { + return ynab.client.accounts.getAccounts(ynab.budgetId); +} export async function scheduledCategoryBalanceUpdate(ynab: YnabAPI): Promise { - log.info(`Starting scheduled category balance update at ${new Date().toLocaleString()} ...`); - const categories = await ynab.getCategoryBudgets(); - return categories; + return ynab.getCategoryBudgets(); } \ No newline at end of file diff --git a/src/metrics.ts b/src/metrics.ts new file mode 100644 index 0000000..f3d8501 --- /dev/null +++ b/src/metrics.ts @@ -0,0 +1,40 @@ +import {Gauge, Registry, collectDefaultMetrics} from "prom-client"; + +const catLabels = ['name', 'category_group_name', 'budgeted_amount', 'activity_amount', 'balance_amount', 'hidden', 'deleted', 'budget_name']; +const accountLabels = ['budget_name', 'account_name', 'type', 'closed']; +export const registry = new Registry(); + +export const ynab_category_balance_amount = new Gauge({ + name: 'ynab_category_balance_amount', + help: 'Category Balance amount', + registers: [registry], + labelNames: catLabels, +}); + +export const ynab_category_activity_amount = new Gauge({ + name: 'ynab_category_activity_amount', + help: 'Category Activity amount', + registers: [registry], + labelNames: catLabels, +}); + +export const ynab_category_budgeted_amount = new Gauge({ + name: 'ynab_category_budgeted_amount', + help: 'Category Budgeted amount', + registers: [registry], + labelNames: catLabels, +}); + +export const ynab_cleared_account_balance = new Gauge({ + name: 'ynab_cleared_account_balance', + help: 'Account Cleared Balance amounts', + registers: [registry], + labelNames: accountLabels, +}); + +export const ynab_uncleared_account_balance = new Gauge({ + name: 'ynab_uncleared_account_balance', + help: 'Account Uncleared Balance amounts', + registers: [registry], + labelNames: accountLabels +}); \ No newline at end of file