Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { usePluginPageUsage } from "./lib/plugin-usage";
import { PluginUpdateJobsHost } from "./components/plugin/PluginUpdateJobsHost";
import { LazyThreadDetailView } from "./views/thread-detail/LazyThreadDetailView";
import { LazyRootComposeView } from "./views/LazyRootComposeView";
Expand Down Expand Up @@ -285,6 +286,7 @@ export function HashNavigationScroll() {
}

export function AppRoutes() {
usePluginPageUsage();
const { isThreadView } = useRouteState();
const isRootComposeView = useLocation().pathname === APP_ROOT_ROUTE_PATH;
useEffect(() => {
Expand Down
2 changes: 2 additions & 0 deletions apps/app/src/components/plugin/PluginPanelActions.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { recordPluginPanelOpen } from "@/lib/plugin-usage";
import { useMemo, type ReactNode } from "react";
import type { PluginPanelActionOpenOptions } from "@get-bb/plugin-sdk";
import { EmptyStatePanel } from "@bb/shared-ui/empty-state";
Expand Down Expand Up @@ -70,6 +71,7 @@ export function createPanelActionOpenPanel({
title: options?.title ?? action.title,
paramsJson,
});
recordPluginPanelOpen(action.pluginId);
return true;
};
}
Expand Down
25 changes: 25 additions & 0 deletions apps/app/src/lib/plugin-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { sdk } from "@/lib/sdk";
import { getPluginPanelRoutePluginId } from "@/lib/route-paths";

export function recordPluginPanelOpen(pluginId: string): void {
try {
void sdk.plugins
.experimental_recordPanelOpen({ pluginId })
.catch(() => undefined);
} catch {}
}

export function usePluginPageUsage(): void {
const { pathname } = useLocation();
const previous = useRef(pathname);
useEffect(() => {
if (previous.current === pathname) return;
const previousPlugin = getPluginPanelRoutePluginId(previous.current);
const pluginId = getPluginPanelRoutePluginId(pathname);
previous.current = pathname;
if (pluginId !== null && pluginId !== previousPlugin)
recordPluginPanelOpen(pluginId);
}, [pathname]);
}
9 changes: 9 additions & 0 deletions apps/server/src/routes/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,15 @@ export function registerPluginRoutes(
});
});

app.post("/plugins/:id/usage", (context) => {
const problem = localAuthProblem(context, deps);
if (problem)
return context.json({ ok: false, error: problem.error }, problem.status);
if (!plugins.recordPanelOpen(context.req.param("id")))
return context.json({ ok: false }, 404);
return context.json({ ok: true });
});

app.get("/plugins/rpc", (context) => {
const query = pluginRpcDiscoveryQuerySchema.safeParse(context.req.query());
if (!query.success)
Expand Down
15 changes: 14 additions & 1 deletion apps/server/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { telemetryTriggerSchema } from "@bb/server-contract";
import { coreFeatureTelemetry } from "./services/system/feature-telemetry.js";
import { createPluginUpdateJobs } from "./services/plugins/plugin-update-jobs.js";
import { registerPluginUpdateJobRoutes } from "./routes/plugin-update-jobs.js";
Expand Down Expand Up @@ -865,7 +866,19 @@ export function createApp(
const publicApi = new Hono();
publicApi.use(
"*",
coreFeatureTelemetry(() => deps.telemetry),
coreFeatureTelemetry(
() => deps.telemetry,
(context) => {
const caller = pluginService.resolveRpcCaller(
context.req.header("x-bb-plugin-caller"),
);
return caller.ok && caller.caller.kind === "plugin"
? (telemetryTriggerSchema.safeParse(
context.req.header("x-bb-plugin-trigger"),
).data ?? "unknown")
: null;
},
),
);
publicApi.use("*", async (context, next) => {
if (PLUGIN_WIRE_HTTP_PATH.test(context.req.path)) {
Expand Down
20 changes: 13 additions & 7 deletions apps/server/src/services/plugins/plugin-api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { telemetryTriggerSchema } from "@bb/server-contract";
import {
environmentCompositionSchema,
validateServerAccessProviderDeclaration,
Expand Down Expand Up @@ -191,6 +192,7 @@ export interface PluginWebSocketRouteRecord {
}

export interface PluginRpcHandler {
usage?: "action" | "query" | null;
publication: ReturnType<typeof publishRpcMethod>;
inputSchema: StandardSchemaV1;
outputSchema: StandardSchemaV1;
Expand Down Expand Up @@ -343,17 +345,13 @@ function withPluginThreadAttribution<
return { ...args, ...attribution };
}

function wrapSdkForPlugin(
sdk: BbSdk,
pluginId: string,
rpcCallerSdk: BbSdk,
): PluginBbSdk {
function wrapSdkForPlugin(pluginId: string, sdk: BbSdk): PluginBbSdk {
return {
...sdk,
plugins: {
...sdk.plugins,
callRpc(args) {
return rpcCallerSdk.plugins.callRpc(args);
return sdk.plugins.callRpc(args);
},
},
threads: {
Expand Down Expand Up @@ -478,6 +476,10 @@ function createStagedRegistrations<
const PLUGIN_HOST_CALL_MAX_TIMEOUT_MS = 30 * 60_000;

export function createPluginApi(options: {
withFeatureUse?: <T>(
trigger: import("@bb/server-contract").TelemetryTrigger,
run: () => T,
) => T;
pluginId: string;
logger: ServerLogger;
db: DbConnection;
Expand Down Expand Up @@ -1323,6 +1325,11 @@ export function createPluginApi(options: {
};

const api: BbPluginApi = {
experimental_withFeatureUse(value, run) {
const parsed = telemetryTriggerSchema.safeParse(value?.trigger);
if (!parsed.success || !options.withFeatureUse) return run();
return options.withFeatureUse(parsed.data, run);
},
pluginId,
log,
settings,
Expand Down Expand Up @@ -1355,7 +1362,6 @@ export function createPluginApi(options: {
);
}
wrappedSdk ??= wrapSdkForPlugin(
sdk,
pluginId,
createPluginRpcCallerSdk({
baseUrl: loopbackBaseUrl,
Expand Down
110 changes: 110 additions & 0 deletions apps/server/src/services/plugins/plugin-feature-telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { getInstalledPlugin, getPluginMarketplace } from "@bb/db";
import {
CURATED_PLUGIN_MARKETPLACE_NAME,
type CoreFeature,
type TelemetryTrigger,
type TelemetryPluginSource,
} from "@bb/server-contract";
import {
CURATED_MARKETPLACE_V1_URL,
CURATED_MARKETPLACE_V2_URL,
} from "../plugin-catalog/marketplace-manifest.js";
import { captureFeatureUsed } from "../system/feature-telemetry.js";
import type { PluginServiceDeps } from "./plugin-service-internal.js";

const invocation = new AsyncLocalStorage<TelemetryTrigger>();
const capabilities: Readonly<Record<string, CoreFeature>> = {
automations: "automation",
"browser-automation": "browser",
connect: "remote_access",
"push-notifications": "remote_access",
github: "git",
"monaco-editor": "file",
docs: "file",
"side-chat": "child_thread",
tasks: "thread",
workflows: "child_thread",
"concurrency-limit": "settings",
"keep-awake": "settings",
"thread-list": "settings",
"theme-preview": "settings",
"storage-retention": "settings",
"bb-account": "settings",
"bb-ai": "provider",
"account-pool": "provider",
"provider-usage": "provider",
"environment-git-worktree": "environment",
"environment-project-checkout": "environment",
"environment-personal-workspace": "environment",
"environment-modal-sandbox": "environment",
};

export function capturePluginUse(
deps: PluginServiceDeps,
pluginId: string,
interaction: "opened" | "invoked" | "ran",
trigger: TelemetryTrigger,
): void {
if (invocation.getStore()) return;
try {
const row = getInstalledPlugin(deps.db, pluginId);
if (!row || !row.enabled) return;
let source: TelemetryPluginSource = "unknown";
if (
row.sourceKind === "path" ||
(row.sourceKind === "builtin" && deps.watchBuiltinPluginSources === true)
)
source = "local";
else if (row.sourceKind === "builtin" && row.provenance === "builtin")
source = "builtin";
else if (
row.provenance === "catalog" &&
row.catalogMarketplaceName !== null
) {
const marketplace = getPluginMarketplace(
deps.db,
row.catalogMarketplaceName,
);
if (
marketplace?.name === CURATED_PLUGIN_MARKETPLACE_NAME &&
marketplace.sourceKind === "https" &&
[CURATED_MARKETPLACE_V1_URL, CURATED_MARKETPLACE_V2_URL].includes(
marketplace.manifestUrl,
)
)
source = "public_catalog";
else if (marketplace?.sourceKind === "git")
source = "private_marketplace";
}
const feature =
row.provenance === "builtin" && row.sourceKind === "builtin"
? (capabilities[pluginId] ??
(pluginId.startsWith("provider-") ? "provider" : "plugin"))
: "plugin";
captureFeatureUsed(deps.telemetry, {
feature,
feature_scope: "plugin",
plugin_source: source,
interaction,
trigger,
});
} catch {}
}

export function withPluginUse<T>(
deps: PluginServiceDeps,
pluginId: string,
trigger: TelemetryTrigger,
run: () => T,
capture = true,
interaction: "invoked" | "ran" = "invoked",
): T {
if (invocation.getStore()) return run();
if (capture) capturePluginUse(deps, pluginId, interaction, trigger);
return invocation.run(trigger, run);
}

export function pluginInvocationTrigger(): TelemetryTrigger {
return invocation.getStore() ?? "unknown";
}
2 changes: 2 additions & 0 deletions apps/server/src/services/plugins/plugin-rpc-caller.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { pluginInvocationTrigger } from "./plugin-feature-telemetry.js";
import { createHash, randomBytes } from "node:crypto";
import {
createNodeBbSdk,
Expand Down Expand Up @@ -63,6 +64,7 @@ export function createPluginRpcCallerSdk(args: {
fetch: (input, init) => {
const headers = new Headers(init?.headers);
headers.set(PLUGIN_RPC_CALLER_HEADER, args.token);
headers.set("x-bb-plugin-trigger", pluginInvocationTrigger());
return timeoutFetch(input, { ...init, headers });
},
});
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/services/plugins/plugin-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { withPluginUse } from "./plugin-feature-telemetry.js";
import type { MachineEnrollmentService } from "../machines/machine-services.js";
import { AsyncLocalStorage } from "node:async_hooks";
import {
Expand Down Expand Up @@ -1621,6 +1622,8 @@ export function createPluginRuntime(context: PluginRuntimeContext) {
current: {},
};
const handle = createPluginApi({
withFeatureUse: (trigger, run) =>
withPluginUse(deps, row.id, trigger, run, true, "ran"),
pluginId: row.id,
logger: deps.logger,
db: deps.db,
Expand Down
30 changes: 25 additions & 5 deletions apps/server/src/services/plugins/plugin-service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { capturePluginUse, withPluginUse } from "./plugin-feature-telemetry.js";
import { currentTelemetryTrigger } from "../system/telemetry.js";
import type {
PluginRpcDiscoveryQuery,
PublishedPluginRpcMethod,
Expand Down Expand Up @@ -412,6 +414,7 @@ export interface PluginService {
options?: { rotate?: boolean },
): Promise<string | undefined>;
listCliContributions(): PluginCliContribution[];
recordPanelOpen(id: string): boolean;
runCliCommand(
id: string,
argv: string[],
Expand Down Expand Up @@ -2149,9 +2152,16 @@ export function createPluginService(deps: PluginServiceDeps): PluginService {
"input",
throwRpcBoundaryError,
);
const result = await handler.handler(parsedInput, {
experimental_caller: caller,
});
const result = await withPluginUse(
deps,
id,
currentTelemetryTrigger(),
() =>
handler.handler(parsedInput, {
experimental_caller: caller,
}),
handler.usage === "action" && caller.kind === "client",
);
const parsedOutput = await validateRpcValue(
handler.outputSchema,
result,
Expand Down Expand Up @@ -2188,6 +2198,12 @@ export function createPluginService(deps: PluginServiceDeps): PluginService {
return cliContributions();
},

recordPanelOpen(id) {
if (!loaded.has(id)) return false;
capturePluginUse(deps, id, "opened", currentTelemetryTrigger());
return true;
},

async runCliCommand(id, argv, ctx) {
const fail = (stderr: string) =>
enforcePluginCliOutputLimit(
Expand All @@ -2211,7 +2227,9 @@ export function createPluginService(deps: PluginServiceDeps): PluginService {
id,
`cli ${registration.name}`,
async () => {
const result = await registration.run(argv, ctx);
const result = await withPluginUse(deps, id, "unknown", () =>
registration.run(argv, ctx),
);
if (typeof result?.exitCode !== "number") {
throw new Error(
"cli run() must return { exitCode: number, stdout?, stderr? }",
Expand Down Expand Up @@ -2440,7 +2458,9 @@ export function createPluginService(deps: PluginServiceDeps): PluginService {
pluginId,
`tool ${record.name}`,
async () => {
const result = await record.execute(parsed.value, ctx);
const result = await withPluginUse(deps, pluginId, "agent", () =>
record.execute(parsed.value, ctx),
);
return normalizeAgentToolResult(record.name, result);
},
);
Expand Down
Loading
Loading