Merge pull request #15772 from Budibase/BUDI-9127/track-usage

Track usage
This commit is contained in:
Adria Navarro 2025-03-24 12:50:49 +01:00 committed by GitHub
commit 06f8cc55ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 211 additions and 24 deletions

View File

@ -1,4 +1,4 @@
import { AnyDocument, Database } from "@budibase/types" import { AnyDocument, Database, Document } from "@budibase/types"
import { JobQueue, Queue, createQueue } from "../queue" import { JobQueue, Queue, createQueue } from "../queue"
import * as dbUtils from "../db" import * as dbUtils from "../db"
@ -70,7 +70,7 @@ export class DocWritethroughProcessor {
} }
} }
export class DocWritethrough { export class DocWritethrough<T extends Document = Document> {
private db: Database private db: Database
private _docId: string private _docId: string
@ -83,7 +83,7 @@ export class DocWritethrough {
return this._docId return this._docId
} }
async patch(data: Record<string, any>) { async patch(data: Partial<T>) {
await DocWritethroughProcessor.queue.add({ await DocWritethroughProcessor.queue.add({
dbName: this.db.name, dbName: this.db.name,
docId: this.docId, docId: this.docId,

View File

@ -124,3 +124,7 @@ export const generateDevInfoID = (userId: string) => {
export const generatePluginID = (name: string) => { export const generatePluginID = (name: string) => {
return `${DocumentType.PLUGIN}${SEPARATOR}${name}` return `${DocumentType.PLUGIN}${SEPARATOR}${name}`
} }
export const generateOAuth2LogID = (id: string) => {
return `${DocumentType.OAUTH2_CONFIG_LOG}${SEPARATOR}${id}`
}

View File

@ -1,5 +1,5 @@
import events from "events" import events from "events"
import { newid } from "../utils" import { newid, timeout } from "../utils"
import { Queue, QueueOptions, JobOptions } from "./queue" import { Queue, QueueOptions, JobOptions } from "./queue"
import { helpers } from "@budibase/shared-core" import { helpers } from "@budibase/shared-core"
import { Job, JobId, JobInformation } from "bull" import { Job, JobId, JobInformation } from "bull"
@ -109,6 +109,12 @@ export class InMemoryQueue<T = any> implements Partial<Queue<T>> {
try { try {
await retryFunc(resp) await retryFunc(resp)
this._emitter.emit("completed", message as Job<T>) this._emitter.emit("completed", message as Job<T>)
const indexToRemove = this._messages.indexOf(message)
if (indexToRemove === -1) {
throw "Failed deleting a processed message"
}
this._messages.splice(indexToRemove, 1)
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e)
} }
@ -248,6 +254,16 @@ export class InMemoryQueue<T = any> implements Partial<Queue<T>> {
.filter(job => job.opts?.repeat != null) .filter(job => job.opts?.repeat != null)
.map(job => jobToJobInformation(job as Job)) .map(job => jobToJobInformation(job as Job))
} }
async whenCurrentJobsFinished() {
do {
await timeout(50)
} while (this.hasRunningJobs())
}
private hasRunningJobs() {
return this._addCount > this._runCount
}
} }
export default InMemoryQueue export default InMemoryQueue

View File

@ -0,0 +1,7 @@
import { processStringSync } from "@budibase/string-templates"
export function durationFromNow(isoDate: string) {
return processStringSync("{{ durationFromNow time 'millisecond' }} ago", {
time: isoDate,
})
}

View File

@ -12,3 +12,4 @@ export {
export * as featureFlag from "./featureFlags" export * as featureFlag from "./featureFlags"
export * as bindings from "./bindings" export * as bindings from "./bindings"
export * from "./confirm" export * from "./confirm"
export * from "./date"

View File

@ -4,6 +4,7 @@
import AddButton from "./AddButton.svelte" import AddButton from "./AddButton.svelte"
import { onMount } from "svelte" import { onMount } from "svelte"
import MoreMenuRenderer from "./MoreMenuRenderer.svelte" import MoreMenuRenderer from "./MoreMenuRenderer.svelte"
import { capitalise, durationFromNow } from "@/helpers"
const schema = { const schema = {
name: { name: {
@ -24,10 +25,14 @@
oauth2.fetch() oauth2.fetch()
}) })
$: configs = $oauth2.configs.map(c => ({ $: configs = $oauth2.configs.map(c => {
lastUsed: "Never used", return {
...c, ...c,
})) lastUsed: c.lastUsage
? `${capitalise(durationFromNow(c.lastUsage))}`
: "Never used",
}
})
</script> </script>
<Layout noPadding> <Layout noPadding>

View File

@ -38,6 +38,7 @@ export class OAuth2Store extends BudiStore<OAuth2StoreState> {
clientId: c.clientId, clientId: c.clientId,
clientSecret: c.clientSecret, clientSecret: c.clientSecret,
method: c.method, method: c.method,
lastUsage: c.lastUsage,
})), })),
loading: false, loading: false,
})) }))

View File

@ -1,3 +1,3 @@
import { OAuth2ConfigResponse } from "@budibase/types" import { FetchOAuth2ConfigsResponse } from "@budibase/types"
export interface OAuth2Config extends OAuth2ConfigResponse {} export type OAuth2Config = FetchOAuth2ConfigsResponse["configs"][0]

View File

@ -2,7 +2,6 @@ import {
FetchOAuth2ConfigsResponse, FetchOAuth2ConfigsResponse,
InsertOAuth2ConfigRequest, InsertOAuth2ConfigRequest,
InsertOAuth2ConfigResponse, InsertOAuth2ConfigResponse,
OAuth2ConfigResponse,
UpdateOAuth2ConfigRequest, UpdateOAuth2ConfigRequest,
UpdateOAuth2ConfigResponse, UpdateOAuth2ConfigResponse,
ValidateConfigRequest, ValidateConfigRequest,
@ -11,7 +10,7 @@ import {
import { BaseAPIClient } from "./types" import { BaseAPIClient } from "./types"
export interface OAuth2Endpoints { export interface OAuth2Endpoints {
fetch: () => Promise<OAuth2ConfigResponse[]> fetch: () => Promise<FetchOAuth2ConfigsResponse["configs"]>
create: ( create: (
config: InsertOAuth2ConfigRequest config: InsertOAuth2ConfigRequest
) => Promise<InsertOAuth2ConfigResponse> ) => Promise<InsertOAuth2ConfigResponse>

View File

@ -30,9 +30,17 @@ function toFetchOAuth2ConfigsResponse(
export async function fetch(ctx: Ctx<void, FetchOAuth2ConfigsResponse>) { export async function fetch(ctx: Ctx<void, FetchOAuth2ConfigsResponse>) {
const configs = await sdk.oauth2.fetch() const configs = await sdk.oauth2.fetch()
const timestamps = await sdk.oauth2.getLastUsages(configs.map(c => c._id))
const response: FetchOAuth2ConfigsResponse = { const response: FetchOAuth2ConfigsResponse = {
configs: (configs || []).map(toFetchOAuth2ConfigsResponse), configs: (configs || []).map(c => ({
...toFetchOAuth2ConfigsResponse(c),
lastUsage: timestamps[c._id]
? new Date(timestamps[c._id]).toISOString()
: undefined,
})),
} }
ctx.body = response ctx.body = response
} }

View File

@ -386,7 +386,7 @@ export class RestIntegration implements IntegrationBase {
} }
if (authConfigType === RestAuthType.OAUTH2) { if (authConfigType === RestAuthType.OAUTH2) {
return { Authorization: await sdk.oauth2.generateToken(authConfigId) } return { Authorization: await sdk.oauth2.getToken(authConfigId) }
} }
if (!this.config.authConfigs) { if (!this.config.authConfigs) {

View File

@ -89,4 +89,9 @@ export async function remove(configId: string, _rev: string): Promise<void> {
} }
throw e throw e
} }
const usageLog = await db.tryGet(docIds.generateOAuth2LogID(configId))
if (usageLog) {
await db.remove(usageLog)
}
} }

View File

@ -1,12 +1,14 @@
import { generator } from "@budibase/backend-core/tests" import { generator, utils as testUtils } from "@budibase/backend-core/tests"
import { GenericContainer, Wait } from "testcontainers" import { GenericContainer, Wait } from "testcontainers"
import sdk from "../../.." import sdk from "../../.."
import TestConfiguration from "../../../../tests/utilities/TestConfiguration" import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
import { generateToken } from "../utils" import { getToken } from "../utils"
import path from "path" import path from "path"
import { KEYCLOAK_IMAGE } from "../../../../integrations/tests/utils/images" import { KEYCLOAK_IMAGE } from "../../../../integrations/tests/utils/images"
import { startContainer } from "../../../../integrations/tests/utils" import { startContainer } from "../../../../integrations/tests/utils"
import { OAuth2CredentialsMethod } from "@budibase/types" import { OAuth2CredentialsMethod } from "@budibase/types"
import { cache } from "@budibase/backend-core"
import tk from "timekeeper"
const config = new TestConfiguration() const config = new TestConfiguration()
@ -43,7 +45,7 @@ describe("oauth2 utils", () => {
}) })
describe.each(Object.values(OAuth2CredentialsMethod))( describe.each(Object.values(OAuth2CredentialsMethod))(
"generateToken (in %s)", "getToken (in %s)",
method => { method => {
it("successfully generates tokens", async () => { it("successfully generates tokens", async () => {
const response = await config.doInContext(config.appId, async () => { const response = await config.doInContext(config.appId, async () => {
@ -55,7 +57,7 @@ describe("oauth2 utils", () => {
method, method,
}) })
const response = await generateToken(oauthConfig._id) const response = await getToken(oauthConfig._id)
return response return response
}) })
@ -73,7 +75,7 @@ describe("oauth2 utils", () => {
method, method,
}) })
await generateToken(oauthConfig._id) await getToken(oauthConfig._id)
}) })
).rejects.toThrow("Error fetching oauth2 token: Not Found") ).rejects.toThrow("Error fetching oauth2 token: Not Found")
}) })
@ -89,7 +91,7 @@ describe("oauth2 utils", () => {
method, method,
}) })
await generateToken(oauthConfig._id) await getToken(oauthConfig._id)
}) })
).rejects.toThrow( ).rejects.toThrow(
"Error fetching oauth2 token: Invalid client or Invalid client credentials" "Error fetching oauth2 token: Invalid client or Invalid client credentials"
@ -107,12 +109,104 @@ describe("oauth2 utils", () => {
method, method,
}) })
await generateToken(oauthConfig._id) await getToken(oauthConfig._id)
}) })
).rejects.toThrow( ).rejects.toThrow(
"Error fetching oauth2 token: Invalid client or Invalid client credentials" "Error fetching oauth2 token: Invalid client or Invalid client credentials"
) )
}) })
describe("track usages", () => {
beforeAll(() => {
tk.freeze(Date.now())
})
it("tracks usages on generation", async () => {
const oauthConfig = await config.doInContext(config.appId, () =>
sdk.oauth2.create({
name: generator.guid(),
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`,
clientId: "my-client",
clientSecret: "my-secret",
method,
})
)
await config.doInContext(config.appId, () =>
getToken(oauthConfig._id)
)
await testUtils.queue.processMessages(
cache.docWritethrough.DocWritethroughProcessor.queue
)
const usageLog = await config.doInContext(config.appId, () =>
sdk.oauth2.getLastUsages([oauthConfig._id])
)
expect(usageLog[oauthConfig._id]).toEqual(Date.now())
})
it("does not track on failed usages", async () => {
const oauthConfig = await config.doInContext(config.appId, () =>
sdk.oauth2.create({
name: generator.guid(),
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`,
clientId: "wrong-client",
clientSecret: "my-secret",
method,
})
)
await expect(
config.doInContext(config.appId, () => getToken(oauthConfig._id))
).rejects.toThrow()
await testUtils.queue.processMessages(
cache.docWritethrough.DocWritethroughProcessor.queue
)
const usageLog = await config.doInContext(config.appId, () =>
sdk.oauth2.getLastUsages([oauthConfig._id])
)
expect(usageLog[oauthConfig._id]).toBeUndefined()
})
it("tracks usages between prod and dev, keeping always the latest", async () => {
const oauthConfig = await config.doInContext(config.appId, () =>
sdk.oauth2.create({
name: generator.guid(),
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`,
clientId: "my-client",
clientSecret: "my-secret",
method,
})
)
await config.doInContext(config.appId, () =>
getToken(oauthConfig._id)
)
await config.publish()
tk.travel(Date.now() + 100)
await config.doInContext(config.prodAppId, () =>
getToken(oauthConfig._id)
)
await testUtils.queue.processMessages(
cache.docWritethrough.DocWritethroughProcessor.queue
)
for (const appId of [config.appId, config.prodAppId]) {
const usageLog = await config.doInContext(appId, () =>
sdk.oauth2.getLastUsages([oauthConfig._id])
)
expect(usageLog).toEqual({
[oauthConfig._id]: Date.now(),
})
}
})
})
} }
) )
}) })

View File

@ -1,7 +1,14 @@
import fetch, { RequestInit } from "node-fetch" import fetch, { RequestInit } from "node-fetch"
import { HttpError } from "koa" import { HttpError } from "koa"
import { get } from "../oauth2" import { get } from "../oauth2"
import { OAuth2CredentialsMethod } from "@budibase/types" import { Document, OAuth2CredentialsMethod } from "@budibase/types"
import { cache, context, docIds } from "@budibase/backend-core"
interface OAuth2LogDocument extends Document {
lastUsage: number
}
const { DocWritethrough } = cache.docWritethrough
async function fetchToken(config: { async function fetchToken(config: {
url: string url: string
@ -40,8 +47,18 @@ async function fetchToken(config: {
return resp return resp
} }
const trackUsage = async (id: string) => {
const writethrough = new DocWritethrough<OAuth2LogDocument>(
context.getAppDB(),
docIds.generateOAuth2LogID(id)
)
await writethrough.patch({
lastUsage: Date.now(),
})
}
// TODO: check if caching is worth // TODO: check if caching is worth
export async function generateToken(id: string) { export async function getToken(id: string) {
const config = await get(id) const config = await get(id)
if (!config) { if (!config) {
throw new HttpError(`oAuth config ${id} count not be found`) throw new HttpError(`oAuth config ${id} count not be found`)
@ -56,6 +73,7 @@ export async function generateToken(id: string) {
throw new Error(`Error fetching oauth2 token: ${message}`) throw new Error(`Error fetching oauth2 token: ${message}`)
} }
await trackUsage(id)
return `${jsonResponse.token_type} ${jsonResponse.access_token}` return `${jsonResponse.token_type} ${jsonResponse.access_token}`
} }
@ -79,3 +97,31 @@ export async function validateConfig(config: {
return { valid: false, message: e.message } return { valid: false, message: e.message }
} }
} }
export async function getLastUsages(ids: string[]) {
const devDocs = await context
.getAppDB()
.getMultiple<OAuth2LogDocument>(ids.map(docIds.generateOAuth2LogID), {
allowMissing: true,
})
const prodDocs = await context
.getProdAppDB()
.getMultiple<OAuth2LogDocument>(ids.map(docIds.generateOAuth2LogID), {
allowMissing: true,
})
const result = ids.reduce<Record<string, number>>((acc, id) => {
const devDoc = devDocs.find(d => d._id === docIds.generateOAuth2LogID(id))
if (devDoc) {
acc[id] = devDoc.lastUsage
}
const prodDoc = prodDocs.find(d => d._id === docIds.generateOAuth2LogID(id))
if (prodDoc && (!acc[id] || acc[id] < prodDoc.lastUsage)) {
acc[id] = prodDoc.lastUsage
}
return acc
}, {})
return result
}

View File

@ -11,7 +11,7 @@ export interface OAuth2ConfigResponse {
} }
export interface FetchOAuth2ConfigsResponse { export interface FetchOAuth2ConfigsResponse {
configs: OAuth2ConfigResponse[] configs: (OAuth2ConfigResponse & { lastUsage?: string })[]
} }
export interface InsertOAuth2ConfigRequest { export interface InsertOAuth2ConfigRequest {

View File

@ -41,6 +41,7 @@ export enum DocumentType {
SCIM_LOG = "scimlog", SCIM_LOG = "scimlog",
ROW_ACTIONS = "ra", ROW_ACTIONS = "ra",
OAUTH2_CONFIG = "oauth2", OAUTH2_CONFIG = "oauth2",
OAUTH2_CONFIG_LOG = "oauth2log",
} }
// Because DocumentTypes can overlap, we need to make sure that we search // Because DocumentTypes can overlap, we need to make sure that we search