Add plugin telemetry bridge capability

Expose telemetry.track through the plugin SDK and server host bridge, forward plugin-prefixed events into the shared telemetry client, and demonstrate the capability in the kitchen sink example.\n\nCo-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-31 13:18:50 -05:00
parent 53dbcd185e
commit af844b778e
14 changed files with 209 additions and 1 deletions

View file

@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createHostClientHandlers } from "../../../packages/plugins/sdk/src/host-client-factory.js";
import { PLUGIN_RPC_ERROR_CODES } from "../../../packages/plugins/sdk/src/protocol.js";
import { buildHostServices } from "../services/plugin-host-services.js";
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
function createEventBusStub() {
return {
forPlugin() {
return {
emit: vi.fn(),
subscribe: vi.fn(),
};
},
} as any;
}
describe("plugin telemetry bridge", () => {
beforeEach(() => {
mockGetTelemetryClient.mockReset();
});
it("prefixes plugin telemetry events before forwarding them to the telemetry client", async () => {
const track = vi.fn();
mockGetTelemetryClient.mockReturnValue({ track });
const services = buildHostServices(
{} as never,
"plugin-record-id",
"linear",
createEventBusStub(),
);
const handlers = createHostClientHandlers({
pluginId: "linear",
capabilities: ["telemetry.track"],
services,
});
await handlers["telemetry.track"]({
eventName: "sync_completed",
dimensions: { attempts: 2, success: true },
});
expect(track).toHaveBeenCalledWith("plugin.linear.sync_completed", {
attempts: 2,
success: true,
});
});
it("rejects invalid bare telemetry event names before prefixing", async () => {
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
const services = buildHostServices(
{} as never,
"plugin-record-id",
"linear",
createEventBusStub(),
);
await expect(
services.telemetry.track({ eventName: "sync.completed" }),
).rejects.toThrow(
'Plugin telemetry event names must be lowercase slugs using letters, numbers, "_" or "-".',
);
});
it("rejects telemetry tracking when the plugin lacks the capability", async () => {
const services = buildHostServices(
{} as never,
"plugin-record-id",
"linear",
createEventBusStub(),
);
const handlers = createHostClientHandlers({
pluginId: "linear",
capabilities: [],
services,
});
await expect(
handlers["telemetry.track"]({ eventName: "sync_completed" }),
).rejects.toMatchObject({
code: PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED,
});
expect(mockGetTelemetryClient).not.toHaveBeenCalled();
});
it("passes telemetry requests through when the plugin declares the capability", async () => {
const services = buildHostServices(
{} as never,
"plugin-record-id",
"linear",
createEventBusStub(),
);
const handlers = createHostClientHandlers({
pluginId: "linear",
capabilities: ["telemetry.track"],
services,
});
await handlers["telemetry.track"]({
eventName: "sync_completed",
dimensions: { source: "manual" },
});
expect(mockGetTelemetryClient).toHaveBeenCalledTimes(1);
});
});

View file

@ -68,6 +68,7 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
"issue.comments.create": ["issue.comments.create"],
"activity.log": ["activity.log.write"],
"metrics.write": ["metrics.write"],
"telemetry.track": ["telemetry.track"],
// Plugin state operations
"plugin.state.get": ["plugin.state.read"],

View file

@ -34,6 +34,7 @@ import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { isIP } from "node:net";
import { logger } from "../middleware/logger.js";
import { getTelemetryClient } from "../telemetry.js";
// ---------------------------------------------------------------------------
// SSRF protection for plugin HTTP fetch
@ -47,6 +48,7 @@ const DNS_LOOKUP_TIMEOUT_MS = 5_000;
/** Only these protocols are allowed for plugin HTTP requests. */
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
const TELEMETRY_EVENT_NAME_REGEX = /^[a-z0-9][a-z0-9_-]*$/;
/**
* Check if an IP address is in a private/reserved range (RFC 1918, loopback,
@ -636,6 +638,20 @@ export function buildHostServices(
},
},
telemetry: {
async track(params) {
const eventName = String(params.eventName ?? "").trim();
if (!TELEMETRY_EVENT_NAME_REGEX.test(eventName)) {
throw new Error(
'Plugin telemetry event names must be lowercase slugs using letters, numbers, "_" or "-".',
);
}
const telemetryClient = getTelemetryClient();
if (!telemetryClient) return;
telemetryClient.track(`plugin.${pluginKey}.${eventName}`, params.dimensions);
},
},
logger: {
async log(params) {
const { level, meta } = params;