From 84ec66514d86f82129c510bddec92e736185ba10 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Tue, 29 Sep 2026 14:45:11 +0000 Subject: [PATCH 1/2] feat(webapp,plugins): provision a metering customer when an organization is created Adds an opt-in billing plugin, in the same shape as the SSO and RBAC plugins: a contract in `@trigger.dev/plugins`, a loader in `@trigger.dev/billing` that dynamically imports an installed implementation and falls back to a no-op, and the host wiring in the webapp. Mono-RevId: d69c1b3adab90b77f66cf5930ea3516e48a3fe6a --- apps/webapp/app/env.server.ts | 4 + apps/webapp/app/models/organization.server.ts | 3 + .../app/services/billingPlugin.server.ts | 25 ++ .../provisionBillingCustomer.server.test.ts | 295 ++++++++++++++++++ .../provisionBillingCustomer.server.ts | 160 ++++++++++ apps/webapp/package.json | 1 + apps/webapp/vitest.config.ts | 1 + internal-packages/billing/package.json | 23 ++ internal-packages/billing/src/fallback.ts | 27 ++ internal-packages/billing/src/index.ts | 123 ++++++++ internal-packages/billing/src/loader.test.ts | 157 ++++++++++ internal-packages/billing/tsconfig.build.json | 12 + internal-packages/billing/tsconfig.json | 17 + internal-packages/billing/vitest.config.ts | 10 + packages/plugins/src/billing.ts | 47 +++ packages/plugins/src/index.ts | 11 + pnpm-lock.yaml | 19 ++ 17 files changed, 935 insertions(+) create mode 100644 apps/webapp/app/services/billingPlugin.server.ts create mode 100644 apps/webapp/app/services/provisionBillingCustomer.server.test.ts create mode 100644 apps/webapp/app/services/provisionBillingCustomer.server.ts create mode 100644 internal-packages/billing/package.json create mode 100644 internal-packages/billing/src/fallback.ts create mode 100644 internal-packages/billing/src/index.ts create mode 100644 internal-packages/billing/src/loader.test.ts create mode 100644 internal-packages/billing/tsconfig.build.json create mode 100644 internal-packages/billing/tsconfig.json create mode 100644 internal-packages/billing/vitest.config.ts create mode 100644 packages/plugins/src/billing.ts diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index bdcf5415d6e..0a265c94734 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -2617,6 +2617,10 @@ const EnvironmentSchema = z // and emits a `sso.revalidation.timeout` warn log — alert on an // elevated rate of those to catch a slow/unhealthy SSO dependency. SSO_SESSION_REVALIDATION_TIMEOUT_MS: z.coerce.number().int().positive().default(2000), + + BILLING_PLUGIN_ENABLED: BoolEnv.default(false), + BILLING_FORCE_FALLBACK: BoolEnv.default(false), + BILLING_DATABASE_CONNECTION_LIMIT: z.coerce.number().int().default(2), }) .and(GithubAppEnvSchema) .and(S2EnvSchema) diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index fc1fd2d7ed3..29d99a7e4d2 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -27,6 +27,7 @@ import { } from "~/services/platform.v3.server"; import { buildDefaultBillingAlerts } from "~/services/billingAlertsDefaults.server"; import { enqueueAttioWorkspaceSync } from "~/services/attio.server"; +import { provisionBillingCustomerForOrg } from "~/services/billingPlugin.server"; import { logger } from "~/services/logger.server"; import { telemetry } from "~/services/telemetry.server"; import { @@ -153,6 +154,8 @@ export async function createOrganization( }, }); + await provisionBillingCustomerForOrg(organization.id); + // Fire-and-forget; never blocks org creation. void enqueueAttioWorkspaceSync({ orgId: organization.id, diff --git a/apps/webapp/app/services/billingPlugin.server.ts b/apps/webapp/app/services/billingPlugin.server.ts new file mode 100644 index 00000000000..5ac88131c14 --- /dev/null +++ b/apps/webapp/app/services/billingPlugin.server.ts @@ -0,0 +1,25 @@ +import billing from "@trigger.dev/billing"; +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { provisionBillingCustomerForNewOrg } from "~/services/provisionBillingCustomer.server"; + +const billingEnabled = env.BILLING_PLUGIN_ENABLED && !env.BILLING_FORCE_FALLBACK; + +const billingPlugin = billing.create({ + forceFallback: !billingEnabled, + database: { + writerUrl: env.CONTROL_PLANE_DATABASE_URL ?? env.DATABASE_URL, + writerConnectionLimit: env.BILLING_DATABASE_CONNECTION_LIMIT, + readerConnectionLimit: env.BILLING_DATABASE_CONNECTION_LIMIT, + }, +}); + +export function provisionBillingCustomerForOrg(organizationId: string): Promise { + return provisionBillingCustomerForNewOrg(organizationId, { + enabled: billingEnabled, + controller: billingPlugin, + deleteOrganization: async (id) => { + await prisma.organization.delete({ where: { id } }); + }, + }); +} diff --git a/apps/webapp/app/services/provisionBillingCustomer.server.test.ts b/apps/webapp/app/services/provisionBillingCustomer.server.test.ts new file mode 100644 index 00000000000..ac266900e38 --- /dev/null +++ b/apps/webapp/app/services/provisionBillingCustomer.server.test.ts @@ -0,0 +1,295 @@ +import type { + BillingController, + BillingCustomer, + BillingCustomerError, + ProvisionBillingCustomerParams, + ProvisionBillingCustomerResult, +} from "@trigger.dev/billing"; +import { errAsync, okAsync, ResultAsync } from "neverthrow"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + abortableSleep, + PROVISION_MAX_ATTEMPTS, + provisionBillingCustomerForNewOrg, + type NewOrgProvisionDependencies, +} from "./provisionBillingCustomer.server"; + +type Outcome = () => ResultAsync; + +const created: Outcome = () => + okAsync({ organizationId: "org_1", billingCustomerId: "cus_1", outcome: "created" as const }); + +const never: Outcome = () => new ResultAsync(new Promise(() => {})); + +function harness(outcomes: Outcome[], options: { usingPlugin?: () => Promise } = {}) { + const calls: ProvisionBillingCustomerParams[] = []; + const deleted: string[] = []; + + const controller: BillingController = { + isUsingPlugin: options.usingPlugin ?? (async () => true), + getCustomer(): ResultAsync { + return okAsync(null); + }, + provisionCustomer(params) { + calls.push(params); + return outcomes[Math.min(calls.length - 1, outcomes.length - 1)](); + }, + }; + + const deps: NewOrgProvisionDependencies = { + enabled: true, + controller, + deleteOrganization: async (id) => { + deleted.push(id); + }, + }; + + return { deps, calls, deleted }; +} + +function run(deps: NewOrgProvisionDependencies) { + return provisionBillingCustomerForNewOrg("org_1", deps).then( + () => "ok" as const, + (error: Error) => error + ); +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("provisionBillingCustomerForNewOrg", () => { + it("does nothing when billing is not enabled", async () => { + const { deps, calls, deleted } = harness([created]); + + expect(await run({ ...deps, enabled: false })).toBe("ok"); + expect(calls).toHaveLength(0); + expect(deleted).toHaveLength(0); + }); + + it("blocks until the customer is provisioned and keeps the org", async () => { + const { deps, calls, deleted } = harness([created]); + + expect(await run(deps)).toBe("ok"); + expect(calls).toHaveLength(1); + expect(calls[0].organizationId).toBe("org_1"); + expect(calls[0].signal).toBeInstanceOf(AbortSignal); + expect(deleted).toHaveLength(0); + }); + + it("accepts an org that was already provisioned", async () => { + const { deps, deleted } = harness([ + () => + okAsync({ + organizationId: "org_1", + billingCustomerId: "cus_1", + outcome: "already_provisioned" as const, + }), + ]); + + expect(await run(deps)).toBe("ok"); + expect(deleted).toHaveLength(0); + }); + + it("fails closed when billing is enabled but the plugin did not load", async () => { + const { deps, calls, deleted } = harness([created], { usingPlugin: async () => false }); + + expect(await run(deps)).toBeInstanceOf(Error); + expect(calls).toHaveLength(0); + expect(deleted).toEqual(["org_1"]); + }); + + it("keeps the org when the plugin did not load and the policy is skip", async () => { + const { deps, deleted } = harness([created], { usingPlugin: async () => false }); + + expect(await run({ ...deps, notConfiguredPolicy: "skip" })).toBe("ok"); + expect(deleted).toHaveLength(0); + }); + + it("ends at the deadline even when plugin initialization never settles", async () => { + const { deps, calls, deleted } = harness([created], { + usingPlugin: () => new Promise(() => {}), + }); + + const settled = run({ ...deps, deadlineMs: 1_000 }); + await vi.advanceTimersByTimeAsync(999); + expect(deleted).toHaveLength(0); + await vi.advanceTimersByTimeAsync(1); + + expect(await settled).toBeInstanceOf(Error); + expect(calls).toHaveLength(0); + expect(deleted).toEqual(["org_1"]); + }); + + it("rolls back when checking the plugin throws", async () => { + const { deps, deleted } = harness([created], { + usingPlugin: async () => { + throw new Error("boom"); + }, + }); + + expect(await run(deps)).toBeInstanceOf(Error); + expect(deleted).toEqual(["org_1"]); + }); + + it("retries a transient failure with exponential backoff, then succeeds", async () => { + const { deps, calls, deleted } = harness([ + () => errAsync("upstream_unavailable"), + () => errAsync("internal"), + created, + ]); + + const settled = run(deps); + await vi.advanceTimersByTimeAsync(0); + expect(calls).toHaveLength(1); + await vi.advanceTimersByTimeAsync(199); + expect(calls).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + expect(calls).toHaveLength(2); + await vi.advanceTimersByTimeAsync(400); + expect(calls).toHaveLength(3); + + expect(await settled).toBe("ok"); + expect(deleted).toHaveLength(0); + }); + + it("retries a provisioning call that throws like a transient failure", async () => { + const { deps, calls } = harness([ + () => new ResultAsync(Promise.reject(new Error("socket hang up"))), + created, + ]); + + const settled = run(deps); + await vi.advanceTimersByTimeAsync(200); + + expect(await settled).toBe("ok"); + expect(calls).toHaveLength(2); + }); + + it("retries an in_progress outcome instead of treating it as done", async () => { + const { deps, calls } = harness([ + () => + okAsync({ + organizationId: "org_1", + billingCustomerId: null, + outcome: "in_progress" as const, + }), + created, + ]); + + const settled = run(deps); + await vi.advanceTimersByTimeAsync(200); + + expect(await settled).toBe("ok"); + expect(calls).toHaveLength(2); + }); + + it("deletes the org and fails creation once retries are exhausted", async () => { + const { deps, calls, deleted } = harness([() => errAsync("upstream_unavailable")]); + + const settled = run(deps); + await vi.advanceTimersByTimeAsync(200 + 400 + 800 + 1600); + const result = await settled; + + expect(result).toBeInstanceOf(Error); + expect((result as Error).message).toBe("Organization could not be created."); + expect(calls).toHaveLength(PROVISION_MAX_ATTEMPTS); + expect(deleted).toEqual(["org_1"]); + }); + + it("fails fast without retrying a permanent failure", async () => { + const { deps, calls, deleted } = harness([() => errAsync("upstream_rejected")]); + + expect(await run(deps)).toBeInstanceOf(Error); + expect(calls).toHaveLength(1); + expect(deleted).toEqual(["org_1"]); + }); + + it("fails org creation when the plugin has no credentials", async () => { + const { deps, calls, deleted } = harness([() => errAsync("not_configured")]); + + expect(await run(deps)).toBeInstanceOf(Error); + expect(calls).toHaveLength(1); + expect(deleted).toEqual(["org_1"]); + }); + + it("keeps the org without a customer when the not-configured policy is skip", async () => { + const { deps, deleted } = harness([() => errAsync("not_configured")]); + + expect(await run({ ...deps, notConfiguredPolicy: "skip" })).toBe("ok"); + expect(deleted).toHaveLength(0); + }); + + it("ends at the deadline even when a provisioning call never settles", async () => { + const { deps, calls, deleted } = harness([never]); + + const settled = run({ ...deps, deadlineMs: 1_000 }); + await vi.advanceTimersByTimeAsync(999); + expect(deleted).toHaveLength(0); + await vi.advanceTimersByTimeAsync(1); + + expect(await settled).toBeInstanceOf(Error); + expect(calls).toHaveLength(1); + expect(calls[0].signal?.aborted).toBe(true); + expect(deleted).toEqual(["org_1"]); + }); + + it("skips a backoff that would run past the deadline", async () => { + const { deps, calls, deleted } = harness([() => errAsync("upstream_unavailable")]); + + const settled = run({ ...deps, deadlineMs: 300 }); + await vi.advanceTimersByTimeAsync(200); + + expect(await settled).toBeInstanceOf(Error); + expect(calls).toHaveLength(2); + expect(deleted).toEqual(["org_1"]); + }); + + it("still fails creation when the rollback delete itself fails", async () => { + const { deps } = harness([() => errAsync("upstream_rejected")]); + + const result = await run({ + ...deps, + deleteOrganization: async () => { + throw new Error("db down"); + }, + }); + + expect((result as Error).message).toBe("Organization could not be created."); + }); +}); + +describe("abortableSleep", () => { + it("resolves as soon as the signal aborts, mid-sleep", async () => { + const controller = new AbortController(); + let resolved = false; + void abortableSleep(10_000, controller.signal).then(() => { + resolved = true; + }); + + await vi.advanceTimersByTimeAsync(100); + expect(resolved).toBe(false); + controller.abort(); + await vi.advanceTimersByTimeAsync(0); + + expect(resolved).toBe(true); + }); + + it("resolves after the timeout when the signal never aborts", async () => { + const controller = new AbortController(); + let resolved = false; + void abortableSleep(500, controller.signal).then(() => { + resolved = true; + }); + + await vi.advanceTimersByTimeAsync(499); + expect(resolved).toBe(false); + await vi.advanceTimersByTimeAsync(1); + + expect(resolved).toBe(true); + }); +}); diff --git a/apps/webapp/app/services/provisionBillingCustomer.server.ts b/apps/webapp/app/services/provisionBillingCustomer.server.ts new file mode 100644 index 00000000000..3302a1e028a --- /dev/null +++ b/apps/webapp/app/services/provisionBillingCustomer.server.ts @@ -0,0 +1,160 @@ +import type { + BillingController, + BillingCustomerError, + ProvisionBillingCustomerResult, +} from "@trigger.dev/billing"; +import { tryCatch } from "@trigger.dev/core/utils"; +import type { Result } from "neverthrow"; +import { logger } from "~/services/logger.server"; + +const PROVISION_DEADLINE_MS = 20_000; +export const PROVISION_MAX_ATTEMPTS = 5; +const BASE_BACKOFF_MS = 200; + +const RETRYABLE_ERRORS: ReadonlySet = new Set([ + "upstream_unavailable", + "internal", +]); + +type NotConfiguredPolicy = "fail" | "skip"; +const NOT_CONFIGURED_POLICY: NotConfiguredPolicy = "fail"; + +export type NewOrgProvisionDependencies = { + enabled: boolean; + controller: BillingController; + deleteOrganization: (organizationId: string) => Promise; + now?: () => number; + deadlineMs?: number; + notConfiguredPolicy?: NotConfiguredPolicy; +}; + +type Budget = { deadlineAt: number; now: () => number; signal: AbortSignal }; + +type ProvisionAttempt = Result | "aborted"; + +export function abortableSleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) return resolve(); + + const done = () => { + clearTimeout(timer); + signal.removeEventListener("abort", done); + resolve(); + }; + const timer = setTimeout(done, ms); + signal.addEventListener("abort", done, { once: true }); + }); +} + +function untilAborted(work: PromiseLike, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) return resolve("aborted"); + + const abort = () => resolve("aborted"); + signal.addEventListener("abort", abort, { once: true }); + Promise.resolve(work) + .then(resolve, reject) + .finally(() => signal.removeEventListener("abort", abort)); + }); +} + +export async function provisionBillingCustomerForNewOrg( + organizationId: string, + deps: NewOrgProvisionDependencies +): Promise { + if (!deps.enabled) return; + + const error = await provisionOrError(organizationId, deps); + if (!error) return; + + logger.error("Billing customer provisioning failed; rolling back organization creation", { + organizationId, + error, + }); + + const [deleteError] = await tryCatch(deps.deleteOrganization(organizationId)); + if (deleteError) { + logger.error("Failed to delete organization after billing provisioning failed", { + organizationId, + error: deleteError instanceof Error ? deleteError.message : deleteError, + }); + } + + throw new Error("Organization could not be created."); +} + +async function provisionOrError( + organizationId: string, + deps: NewOrgProvisionDependencies +): Promise { + const notConfiguredPolicy = deps.notConfiguredPolicy ?? NOT_CONFIGURED_POLICY; + const now = deps.now ?? (() => performance.now()); + const deadlineMs = deps.deadlineMs ?? PROVISION_DEADLINE_MS; + const deadline = new AbortController(); + const timer = setTimeout(() => deadline.abort(), deadlineMs); + const budget: Budget = { deadlineAt: now() + deadlineMs, now, signal: deadline.signal }; + + try { + const [loadError, usingPlugin] = await tryCatch( + untilAborted(deps.controller.isUsingPlugin(), budget.signal) + ); + if (loadError) return "internal"; + if (usingPlugin === "aborted") return "upstream_unavailable"; + if (!usingPlugin) return notConfigured(organizationId, notConfiguredPolicy); + + const error = await provisionWithRetry(organizationId, deps, budget); + if (error === "not_configured") return notConfigured(organizationId, notConfiguredPolicy); + return error; + } finally { + clearTimeout(timer); + } +} + +function notConfigured( + organizationId: string, + policy: NotConfiguredPolicy +): BillingCustomerError | undefined { + if (policy === "fail") return "not_configured"; + + logger.warn("Billing plugin is not usable; creating organization without a customer", { + organizationId, + }); + return undefined; +} + +async function provisionWithRetry( + organizationId: string, + deps: NewOrgProvisionDependencies, + { deadlineAt, now, signal }: Budget +): Promise { + let lastError: BillingCustomerError = "internal"; + + for (let attempt = 1; attempt <= PROVISION_MAX_ATTEMPTS; attempt++) { + const [thrown, result] = await tryCatch( + untilAborted(deps.controller.provisionCustomer({ organizationId, signal }), signal) + ); + + if (thrown) { + logger.error("Billing customer provisioning threw", { + organizationId, + error: thrown instanceof Error ? thrown.message : thrown, + }); + lastError = "internal"; + } else if (result === "aborted") { + return "upstream_unavailable"; + } else if (result.isOk()) { + if (result.value.outcome !== "in_progress") return undefined; + lastError = "internal"; + } else { + if (!RETRYABLE_ERRORS.has(result.error)) return result.error; + lastError = result.error; + } + + const backoffMs = BASE_BACKOFF_MS * 2 ** (attempt - 1); + if (attempt === PROVISION_MAX_ATTEMPTS || signal.aborted) break; + if (deadlineAt - now() <= backoffMs) break; + await abortableSleep(backoffMs, signal); + } + + return lastError; +} diff --git a/apps/webapp/package.json b/apps/webapp/package.json index c6f0f3bc835..adcb04c4335 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -125,6 +125,7 @@ "@team-plain/graphql": "^1.3.0", "@team-plain/ui-components": "^5.0.0", "@trigger.dev/companyicons": "^1.5.35", + "@trigger.dev/billing": "workspace:*", "@trigger.dev/core": "workspace:*", "@trigger.dev/database": "workspace:*", "@trigger.dev/otlp-importer": "workspace:*", diff --git a/apps/webapp/vitest.config.ts b/apps/webapp/vitest.config.ts index 2cfe9114ad0..1294a6ed858 100644 --- a/apps/webapp/vitest.config.ts +++ b/apps/webapp/vitest.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ "app/runEngine/services/**/*.test.ts", "app/services/realtime/**/*.test.ts", "app/services/previewAutoArchive.server.test.ts", + "app/services/provisionBillingCustomer.server.test.ts", "app/services/clickhouse/**/*.test.ts", "app/utils/**/*.test.ts", "app/hooks/**/*.test.ts", diff --git a/internal-packages/billing/package.json b/internal-packages/billing/package.json new file mode 100644 index 00000000000..79b558d4682 --- /dev/null +++ b/internal-packages/billing/package.json @@ -0,0 +1,23 @@ +{ + "name": "@trigger.dev/billing", + "private": true, + "version": "0.0.1", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "dependencies": { + "@trigger.dev/plugins": "workspace:*", + "neverthrow": "^8.2.0" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "rimraf": "6.0.1" + }, + "scripts": { + "clean": "rimraf dist", + "typecheck": "tsc --noEmit", + "build": "pnpm run clean && tsc -p tsconfig.build.json", + "dev": "tsc --noEmit false --outDir dist --declaration --watch", + "test": "vitest run", + "test:watch": "vitest" + } +} diff --git a/internal-packages/billing/src/fallback.ts b/internal-packages/billing/src/fallback.ts new file mode 100644 index 00000000000..4b3ddc26e39 --- /dev/null +++ b/internal-packages/billing/src/fallback.ts @@ -0,0 +1,27 @@ +import type { + BillingController, + BillingCustomer, + BillingCustomerError, + ProvisionBillingCustomerResult, +} from "@trigger.dev/plugins"; +import { errAsync, okAsync, type ResultAsync } from "neverthrow"; + +export class BillingFallback { + create(): BillingController { + return new BillingFallbackController(); + } +} + +class BillingFallbackController implements BillingController { + async isUsingPlugin(): Promise { + return false; + } + + getCustomer(_organizationId: string): ResultAsync { + return okAsync(null); + } + + provisionCustomer(): ResultAsync { + return errAsync("feature_disabled"); + } +} diff --git a/internal-packages/billing/src/index.ts b/internal-packages/billing/src/index.ts new file mode 100644 index 00000000000..a76c6bc0283 --- /dev/null +++ b/internal-packages/billing/src/index.ts @@ -0,0 +1,123 @@ +import type { + BillingController, + BillingCustomer, + BillingCustomerError, + BillingPlugin, + PluginDatabaseConfig, + ProvisionBillingCustomerParams, + ProvisionBillingCustomerResult, +} from "@trigger.dev/plugins"; +import { ResultAsync } from "neverthrow"; +import { BillingFallback } from "./fallback.js"; + +export type { + BillingController, + BillingCustomer, + BillingCustomerError, + ProvisionBillingCustomerParams, + ProvisionBillingCustomerResult, +} from "@trigger.dev/plugins"; + +export type BillingCreateOptions = { + forceFallback?: boolean; + importer?: (moduleName: string) => Promise<{ default: BillingPlugin }>; + database?: PluginDatabaseConfig; +}; + +const MODULE_NAME = "@triggerdotdev/plugins/billing"; + +export class LazyController implements BillingController { + private readonly _init: Promise; + + constructor(options?: BillingCreateOptions) { + this._init = this.load(options); + // Defensive: nothing awaits _init until the first method call, so if + // load() ever rejected unexpectedly, it would surface as an + // unhandledRejection and crash the process before then. + this._init.catch(() => {}); + } + + private async load(options?: BillingCreateOptions): Promise { + if (options?.forceFallback) { + return new BillingFallback().create(); + } + + const importer = + options?.importer ?? + ((m: string) => import(/* @vite-ignore */ m) as Promise<{ default: BillingPlugin }>); + + const imported = await ResultAsync.fromPromise(importer(MODULE_NAME), (error) => error); + + if (imported.isOk()) { + const plugin = imported.value.default; + const created = await ResultAsync.fromPromise( + Promise.resolve().then(() => plugin.create({ database: options?.database })), + (error) => error + ); + + if (created.isOk()) { + console.log("Billing: using plugin implementation"); + return created.value; + } + + console.error( + "Billing: plugin found but failed to initialise; falling back to default implementation", + created.error + ); + return new BillingFallback().create(); + } + + // Node throws ERR_MODULE_NOT_FOUND both when the plugin is absent (expected + // on OSS deployments) and when the plugin loaded but a transitive import + // failed (a real bug). Disambiguate on the missing specifier. + const error = imported.error; + const code = (error as NodeJS.ErrnoException | undefined)?.code; + const message = error instanceof Error ? error.message : String(error); + const isModuleNotFound = code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND"; + + if (!isModuleNotFound || !message.includes(MODULE_NAME)) { + console.error( + "Billing: plugin found but failed to load; falling back to default implementation", + error + ); + return new BillingFallback().create(); + } + + if (process.env.BILLING_LOG_FALLBACK === "1" || process.env.BILLING_LOG_FALLBACK === "true") { + console.log("Billing: no plugin installed (ERR_MODULE_NOT_FOUND); using fallback"); + } + return new BillingFallback().create(); + } + + private c(): Promise { + return this._init; + } + + private call(factory: (c: BillingController) => ResultAsync): ResultAsync { + return ResultAsync.fromSafePromise(this.c().then(factory)).andThen((r) => r); + } + + async isUsingPlugin(): Promise { + return (await this.c()).isUsingPlugin(); + } + + getCustomer(organizationId: string): ResultAsync { + return this.call((c) => c.getCustomer(organizationId)); + } + + provisionCustomer( + params: ProvisionBillingCustomerParams + ): ResultAsync { + return this.call((c) => c.provisionCustomer(params)); + } +} + +class Billing { + create(options?: BillingCreateOptions): BillingController { + return new LazyController(options); + } +} + +const loader = new Billing(); + +export default loader; diff --git a/internal-packages/billing/src/loader.test.ts b/internal-packages/billing/src/loader.test.ts new file mode 100644 index 00000000000..c8f51285959 --- /dev/null +++ b/internal-packages/billing/src/loader.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + BillingController, + BillingPlugin, + ProvisionBillingCustomerParams, +} from "@trigger.dev/plugins"; +import { okAsync } from "neverthrow"; +import loader, { LazyController } from "./index.js"; + +function makeStubController(overrides: Partial = {}): BillingController { + const stub: BillingController = { + async isUsingPlugin() { + return true; + }, + getCustomer(organizationId: string) { + return okAsync({ organizationId, billingCustomerId: "cus_stub" }); + }, + provisionCustomer(params: ProvisionBillingCustomerParams) { + return okAsync({ + organizationId: params.organizationId, + billingCustomerId: "cus_stub", + outcome: "created" as const, + }); + }, + ...overrides, + }; + return stub; +} + +function makePluginModule(controller: BillingController): { default: BillingPlugin } { + return { default: { create: () => controller } }; +} + +function moduleNotFound(specifier: string): NodeJS.ErrnoException { + const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); + error.code = "ERR_MODULE_NOT_FOUND"; + return error; +} + +describe("billing loader", () => { + it("uses the plugin when the import resolves", async () => { + const controller = new LazyController({ + importer: async () => makePluginModule(makeStubController()), + }); + + await expect(controller.isUsingPlugin()).resolves.toBe(true); + + const result = await controller.provisionCustomer({ organizationId: "org_1", name: "Acme" }); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toEqual({ + organizationId: "org_1", + billingCustomerId: "cus_stub", + outcome: "created", + }); + }); + + it("imports the plugin from the @triggerdotdev/plugins/billing subpath", async () => { + const importer = vi.fn(async () => makePluginModule(makeStubController())); + + const controller = new LazyController({ importer }); + await controller.isUsingPlugin(); + + expect(importer).toHaveBeenCalledWith("@triggerdotdev/plugins/billing"); + }); + + it("passes the host database config through to the plugin factory", async () => { + const create = vi.fn(() => makeStubController()); + const controller = new LazyController({ + importer: async () => ({ default: { create } }), + database: { writerUrl: "postgres://writer", readerUrl: "postgres://reader" }, + }); + + await controller.isUsingPlugin(); + + expect(create).toHaveBeenCalledWith({ + database: { writerUrl: "postgres://writer", readerUrl: "postgres://reader" }, + }); + }); + + it("falls back when the plugin is not installed", async () => { + const controller = new LazyController({ + importer: async () => { + throw moduleNotFound("@triggerdotdev/plugins/billing"); + }, + }); + + await expect(controller.isUsingPlugin()).resolves.toBe(false); + + const result = await controller.provisionCustomer({ organizationId: "org_1", name: "Acme" }); + expect(result._unsafeUnwrapErr()).toBe("feature_disabled"); + }); + + it("falls back loudly when the plugin is present but fails to initialise", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + const controller = new LazyController({ + importer: async () => { + throw moduleNotFound("some-transitive-dependency"); + }, + }); + + await expect(controller.isUsingPlugin()).resolves.toBe(false); + expect(consoleError).toHaveBeenCalled(); + + consoleError.mockRestore(); + }); + + it("falls back without rejecting when the plugin's create() throws synchronously", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + const controller = new LazyController({ + importer: async () => ({ + default: { + create: () => { + throw new Error("bad database config"); + }, + }, + }), + }); + + await expect(controller.isUsingPlugin()).resolves.toBe(false); + const result = await controller.provisionCustomer({ organizationId: "org_1", name: "Acme" }); + expect(result._unsafeUnwrapErr()).toBe("feature_disabled"); + expect(consoleError).toHaveBeenCalled(); + + consoleError.mockRestore(); + }); + + it("forceFallback skips the importer entirely", async () => { + const importer = vi.fn(async () => makePluginModule(makeStubController())); + + const controller = loader.create({ forceFallback: true, importer }); + + await expect(controller.isUsingPlugin()).resolves.toBe(false); + expect(importer).not.toHaveBeenCalled(); + }); + + it("fallback reads report no customer rather than an error", async () => { + const controller = loader.create({ forceFallback: true }); + + const result = await controller.getCustomer("org_1"); + expect(result._unsafeUnwrap()).toBeNull(); + }); + + it("loads the plugin once and reuses it across calls", async () => { + const importer = vi.fn(async () => makePluginModule(makeStubController())); + const controller = new LazyController({ importer }); + + await Promise.all([ + controller.isUsingPlugin(), + controller.getCustomer("org_1"), + controller.provisionCustomer({ organizationId: "org_1", name: "Acme" }), + ]); + + expect(importer).toHaveBeenCalledTimes(1); + }); +}); diff --git a/internal-packages/billing/tsconfig.build.json b/internal-packages/billing/tsconfig.build.json new file mode 100644 index 00000000000..1f858270c60 --- /dev/null +++ b/internal-packages/billing/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"], + "compilerOptions": { + "noEmit": false, + "declaration": true, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + } +} diff --git a/internal-packages/billing/tsconfig.json b/internal-packages/billing/tsconfig.json new file mode 100644 index 00000000000..9ab7d813559 --- /dev/null +++ b/internal-packages/billing/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2019", + "lib": ["ES2019", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "preserveWatchOutput": true, + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "customConditions": ["@triggerdotdev/source"] + }, + "exclude": ["node_modules", "dist"] +} diff --git a/internal-packages/billing/vitest.config.ts b/internal-packages/billing/vitest.config.ts new file mode 100644 index 00000000000..e07f05e842b --- /dev/null +++ b/internal-packages/billing/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["**/*.test.ts"], + globals: true, + isolate: true, + testTimeout: 10_000, + }, +}); diff --git a/packages/plugins/src/billing.ts b/packages/plugins/src/billing.ts new file mode 100644 index 00000000000..e2b1f920789 --- /dev/null +++ b/packages/plugins/src/billing.ts @@ -0,0 +1,47 @@ +import type { ResultAsync } from "neverthrow"; +import type { PluginDatabaseConfig } from "./databaseConfig.js"; + +export type BillingCustomerError = + | "feature_disabled" + | "not_configured" + | "organization_not_found" + | "upstream_unavailable" + | "upstream_rejected" + | "internal"; + +export type BillingCustomer = { + organizationId: string; + billingCustomerId: string; +}; + +export type ProvisionBillingCustomerOutcome = "created" | "already_provisioned" | "in_progress"; + +export type ProvisionBillingCustomerResult = { + organizationId: string; + billingCustomerId: string | null; + outcome: ProvisionBillingCustomerOutcome; +}; + +export type ProvisionBillingCustomerParams = { + organizationId: string; + name?: string; + signal?: AbortSignal; +}; + +export interface BillingController { + isUsingPlugin(): Promise; + + getCustomer(organizationId: string): ResultAsync; + + provisionCustomer( + params: ProvisionBillingCustomerParams + ): ResultAsync; +} + +export type BillingPluginConfig = { + database?: PluginDatabaseConfig; +}; + +export interface BillingPlugin { + create(config?: BillingPluginConfig): BillingController | Promise; +} diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index efaabedf51f..54120b6f29f 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -71,3 +71,14 @@ export type { } from "./sso.js"; export { SSO_FLOWS } from "./sso.js"; + +export type { + BillingPlugin, + BillingPluginConfig, + BillingController, + BillingCustomer, + BillingCustomerError, + ProvisionBillingCustomerParams, + ProvisionBillingCustomerResult, + ProvisionBillingCustomerOutcome, +} from "./billing.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 023abb59a31..fd841913353 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -484,6 +484,9 @@ importers: '@team-plain/ui-components': specifier: ^5.0.0 version: 5.0.0(@team-plain/graphql@1.3.0) + '@trigger.dev/billing': + specifier: workspace:* + version: link:../../internal-packages/billing '@trigger.dev/companyicons': specifier: ^1.5.35 version: 1.5.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -902,6 +905,22 @@ importers: docs: {} + internal-packages/billing: + dependencies: + '@trigger.dev/plugins': + specifier: workspace:* + version: link:../../packages/plugins + neverthrow: + specifier: ^8.2.0 + version: 8.2.0 + devDependencies: + '@types/node': + specifier: 24.13.3 + version: 24.13.3 + rimraf: + specifier: 6.0.1 + version: 6.0.1 + internal-packages/cache: dependencies: '@internal/redis': From 9d38ff508779ab7ae4c1e50d119cddd345a93d0f Mon Sep 17 00:00:00 2001 From: Iss <74388823+isshaddad@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:10:10 +0000 Subject: [PATCH 2/2] feat(cli,webapp): add a submit_feedback MCP tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `submit_feedback` MCP tool. Coding agents hit confusing tool errors, docs that disagree with actual behaviour, and missing capabilities, then work around them silently — none of which we ever see. The tool lets the agent report the problem itself, without the user having to file it by hand. `submit_feedback` takes a `message` (what was attempted, what happened, what was expected) and an optional `toolName` and `projectRef`. The CLI version is attached automatically. The tool description tells the agent to describe the problem in its own words rather than pasting raw output, to leave out secrets, credentials, environment variables and the user's own data, to tell the user what it reported, and not to file the same problem twice. Reports go straight from the CLI to analytics, attributed to the signed-in user. Reporting is off when `--skip-telemetry` is passed or `TRIGGER_TELEMETRY_DISABLED` is set, and while it is off the tool is hidden rather than left to fail. Mono-RevId: 8ec525a24ad2049ec7bdb0f13c156c12c763e411 --- .changeset/mcp-submit-feedback.md | 5 ++ packages/cli-v3/package.json | 3 +- packages/cli-v3/src/consts.ts | 4 ++ packages/cli-v3/src/mcp/config.ts | 6 ++ packages/cli-v3/src/mcp/context.ts | 1 + packages/cli-v3/src/mcp/contextOptions.ts | 1 + packages/cli-v3/src/mcp/telemetry.ts | 65 ++++++++++++++++++++ packages/cli-v3/src/mcp/tools.ts | 9 +++ packages/cli-v3/src/mcp/tools/feedback.ts | 74 +++++++++++++++++++++++ pnpm-lock.yaml | 3 + 10 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 .changeset/mcp-submit-feedback.md create mode 100644 packages/cli-v3/src/mcp/telemetry.ts create mode 100644 packages/cli-v3/src/mcp/tools/feedback.ts diff --git a/.changeset/mcp-submit-feedback.md b/.changeset/mcp-submit-feedback.md new file mode 100644 index 00000000000..fb21c234de3 --- /dev/null +++ b/.changeset/mcp-submit-feedback.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Added a `submit_feedback` MCP tool so coding agents can report a confusing tool error, a docs mismatch, or a missing capability without the user having to file it by hand. Turn it off with `--skip-telemetry` or `TRIGGER_TELEMETRY_DISABLED`; the tool is hidden while it is off. diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index b4fa8f08ecd..1cd34f9c7ad 100644 --- a/packages/cli-v3/package.json +++ b/packages/cli-v3/package.json @@ -76,6 +76,7 @@ "mcp:smoke": "tsx src/mcp/smoke.test.ts" }, "dependencies": { + "@babel/parser": "^7.29.7", "@clack/prompts": "0.11.0", "@depot/cli": "0.0.1-cli.2.80.0", "@modelcontextprotocol/sdk": "^1.25.2", @@ -107,7 +108,6 @@ "ini": "^5.0.0", "json-stable-stringify": "^1.3.0", "jsonc-parser": "3.2.1", - "@babel/parser": "^7.29.7", "magicast": "^0.3.4", "minimatch": "^10.0.1", "mlly": "^1.7.1", @@ -116,6 +116,7 @@ "p-limit": "^6.2.0", "p-retry": "^6.1.0", "pkg-types": "^1.1.3", + "posthog-node": "5.35.6", "resolve": "^1.22.8", "semver": "^7.5.0", "signal-exit": "^4.1.0", diff --git a/packages/cli-v3/src/consts.ts b/packages/cli-v3/src/consts.ts index ff1aee64010..d1d8534c6dd 100644 --- a/packages/cli-v3/src/consts.ts +++ b/packages/cli-v3/src/consts.ts @@ -1,3 +1,7 @@ export const COMMAND_NAME = "trigger.dev"; export const CLOUD_WEB_URL = "https://cloud.trigger.dev"; export const CLOUD_API_URL = "https://api.trigger.dev"; + +// Write-only project key, the same one the dashboard already ships to every browser. +export const POSTHOG_PROJECT_KEY = "phc_LFH7kJiGhdIlnO22hTAKgHpaKhpM8gkzWAFvHmf5vfS"; +export const POSTHOG_INGEST_HOST = "https://eu.i.posthog.com"; diff --git a/packages/cli-v3/src/mcp/config.ts b/packages/cli-v3/src/mcp/config.ts index a1e3f194b7c..b25505ac0e4 100644 --- a/packages/cli-v3/src/mcp/config.ts +++ b/packages/cli-v3/src/mcp/config.ts @@ -16,6 +16,12 @@ export const toolsMetadata = { description: "Search across the Trigger.dev documentation to find relevant information, code examples, API references, and guides. Use this tool when you need to answer questions about Trigger.dev, find specific documentation, understand how features work, or locate implementation details. The search returns contextual content with titles and direct links to the documentation pages", }, + submit_feedback: { + name: "submit_feedback", + title: "Submit Feedback", + description: + "Report a problem with the Trigger.dev MCP server, SDK, or documentation. Use this when a tool returned a confusing error, the docs disagreed with the actual behaviour, a capability you needed was missing, or you had to work around something to finish the user's task. Describe what you were trying to do, what happened, and what you expected instead, in your own words. Never include secrets, credentials, environment variables, tokens or the user's own data - summarise and redact instead of pasting raw output. Tell the user what you reported. Do not call this more than once for the same problem.", + }, list_projects: { name: "list_projects", title: "List Projects", diff --git a/packages/cli-v3/src/mcp/context.ts b/packages/cli-v3/src/mcp/context.ts index b18cb9d5947..3ab7f54128a 100644 --- a/packages/cli-v3/src/mcp/context.ts +++ b/packages/cli-v3/src/mcp/context.ts @@ -46,6 +46,7 @@ export type McpContextOptions = { profile?: string; devOnly?: boolean; readonly?: boolean; + skipTelemetry?: boolean; }; export class McpContext { diff --git a/packages/cli-v3/src/mcp/contextOptions.ts b/packages/cli-v3/src/mcp/contextOptions.ts index 6eef141f63c..784c9bcf746 100644 --- a/packages/cli-v3/src/mcp/contextOptions.ts +++ b/packages/cli-v3/src/mcp/contextOptions.ts @@ -22,5 +22,6 @@ export function toMcpContextOptions( profile: options.profile, readonly: options.readonly, devOnly: options.devOnly, + skipTelemetry: options.skipTelemetry, }; } diff --git a/packages/cli-v3/src/mcp/telemetry.ts b/packages/cli-v3/src/mcp/telemetry.ts new file mode 100644 index 00000000000..2878fb10905 --- /dev/null +++ b/packages/cli-v3/src/mcp/telemetry.ts @@ -0,0 +1,65 @@ +import { PostHog } from "posthog-node"; +import { env } from "std-env"; +import { POSTHOG_INGEST_HOST, POSTHOG_PROJECT_KEY } from "../consts.js"; +import type { McpContextOptions } from "./context.js"; + +/** + * Analytics for the MCP server, sent straight from the CLI. + * + * Off when the user says so, by `--skip-telemetry` or TRIGGER_TELEMETRY_DISABLED (the same + * variable the webapp honours). Tools that report anywhere are hidden while it is off, rather + * than left to fail at call time. + */ +export function isTelemetryEnabled(options: McpContextOptions): boolean { + if (options.skipTelemetry) { + return false; + } + + return env.TRIGGER_TELEMETRY_DISABLED === undefined; +} + +export type FeedbackEvent = { + userId: string; + message: string; + toolName?: string; + projectRef?: string; + cliVersion: string; +}; + +/** + * Captures a feedback report and waits for it to leave the machine. A stdio MCP server dies + * whenever its client closes it, so an unflushed report would be lost while the agent had + * already been told it was sent. + */ +export async function captureFeedback(event: FeedbackEvent): Promise { + // A client per report, shut down straight after. flush() resolves without delivering + // anything - only shutdown() drains the queue - and reports are rare enough that the + // extra client costs nothing next to reporting success for something never sent. + const posthog = new PostHog(POSTHOG_PROJECT_KEY, { host: POSTHOG_INGEST_HOST }); + + // shutdown() swallows fetch failures - it logs them and resolves - so without this the tool + // would report a delivery that never happened. The error event is the only signal. + let deliveryError: Error | undefined; + posthog.on("error", (error: unknown) => { + deliveryError ??= error instanceof Error ? error : new Error(String(error)); + }); + + posthog.capture({ + distinctId: event.userId, + event: "mcp_feedback_submitted", + properties: { + message: event.message, + toolName: event.toolName, + projectRef: event.projectRef, + cliVersion: event.cliVersion, + source: "mcp", + }, + groups: event.projectRef ? { project: event.projectRef } : undefined, + }); + + await posthog.shutdown(); + + if (deliveryError) { + throw deliveryError; + } +} diff --git a/packages/cli-v3/src/mcp/tools.ts b/packages/cli-v3/src/mcp/tools.ts index 080dfff774e..118f4906b48 100644 --- a/packages/cli-v3/src/mcp/tools.ts +++ b/packages/cli-v3/src/mcp/tools.ts @@ -1,6 +1,7 @@ import type { McpContext } from "./context.js"; import { deployTool, listDeploysTool } from "./tools/deploys.js"; import { searchDocsTool } from "./tools/docs.js"; +import { submitFeedbackTool } from "./tools/feedback.js"; import { createProjectInOrgTool, initializeProjectTool, @@ -34,6 +35,7 @@ import { listAgentsTool } from "./tools/agents.js"; import { startAgentChatTool, sendAgentMessageTool, closeAgentChatTool } from "./tools/agentChat.js"; import { readSessionChannelTool, writeSessionChannelTool } from "./tools/sessionChannels.js"; import { respondWithError } from "./utils.js"; +import { isTelemetryEnabled } from "./telemetry.js"; /** Tool names that perform write/mutating operations. */ const WRITE_TOOLS = new Set([ @@ -51,11 +53,13 @@ const WRITE_TOOLS = new Set([ sendAgentMessageTool.name, closeAgentChatTool.name, writeSessionChannelTool.name, + submitFeedbackTool.name, ]); export function registerTools(context: McpContext) { const tools = [ searchDocsTool, + submitFeedbackTool, listOrgsTool, listProjectsTool, createProjectInOrgTool, @@ -103,6 +107,11 @@ export function registerTools(context: McpContext) { continue; } + // Nothing to report to, so don't show the model a tool that can only fail. + if (tool.name === submitFeedbackTool.name && !isTelemetryEnabled(context.options)) { + continue; + } + const isWrite = WRITE_TOOLS.has(tool.name); context.server.registerTool( diff --git a/packages/cli-v3/src/mcp/tools/feedback.ts b/packages/cli-v3/src/mcp/tools/feedback.ts new file mode 100644 index 00000000000..080feede592 --- /dev/null +++ b/packages/cli-v3/src/mcp/tools/feedback.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; +import { toolsMetadata } from "../config.js"; +import { respondWithError, toolHandler } from "../utils.js"; +import { captureFeedback } from "../telemetry.js"; +import { VERSION } from "../../version.js"; + +const MAX_MESSAGE_LENGTH = 4000; +const MAX_TOOL_NAME_LENGTH = 100; +// Bounded because it is sent as an analytics group key, where junk values persist. +const MAX_PROJECT_REF_LENGTH = 100; + +const SubmitFeedbackInput = { + message: z + .string() + .min(1) + .max(MAX_MESSAGE_LENGTH) + .describe( + "What you were trying to do, what actually happened, and what you expected instead, in your own words. Summarise the failing call and error rather than pasting raw output, and leave out secrets, credentials, environment variables and the user's own data." + ), + toolName: z + .string() + .max(MAX_TOOL_NAME_LENGTH) + .optional() + .describe("The MCP tool the problem happened in, if it was one tool in particular."), + projectRef: z + .string() + .max(MAX_PROJECT_REF_LENGTH) + .startsWith("proj_") + .optional() + .describe( + "The trigger.dev project ref, starts with proj_. Optional: a report without one is still useful." + ), +}; + +export const submitFeedbackTool = { + name: toolsMetadata.submit_feedback.name, + title: toolsMetadata.submit_feedback.title, + description: toolsMetadata.submit_feedback.description, + inputSchema: SubmitFeedbackInput, + handler: toolHandler(SubmitFeedbackInput, async (input, { ctx }) => { + ctx.logger?.log("calling submit_feedback", { + toolName: input.toolName, + messageLength: input.message.length, + }); + + const auth = await ctx.getAuth(); + + try { + await captureFeedback({ + userId: auth.userId, + message: input.message, + toolName: input.toolName, + projectRef: input.projectRef, + cliVersion: VERSION, + }); + } catch (error) { + // Only this tool is affected - nothing else here reports anywhere. + return respondWithError( + `Your feedback was not recorded - ${ + error instanceof Error ? error.message : String(error) + }. Tell the user the report could not be filed; every other tool still works.` + ); + } + + return { + content: [ + { + type: "text" as const, + text: "Thanks — your feedback was sent to the Trigger.dev team. Tell the user you reported it, and carry on with the task.", + }, + ], + }; + }), +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd841913353..4b0a6a7645d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1687,6 +1687,9 @@ importers: pkg-types: specifier: ^1.1.3 version: 1.1.3 + posthog-node: + specifier: 5.35.6 + version: 5.35.6(rxjs@7.8.2) resolve: specifier: ^1.22.8 version: 1.22.8