diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts b/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts index bb3215c2..bcff32c2 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts @@ -41,6 +41,7 @@ const manifest: PaperclipPluginManifestV1 = { "goals.update", "activity.log.write", "metrics.write", + "telemetry.track", "plugin.state.read", "plugin.state.write", "events.subscribe", diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts b/packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts index a0bf2158..a20b21de 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts @@ -405,6 +405,16 @@ async function registerActionHandlers(ctx: PluginContext): Promise { data: { companyId }, }); await ctx.metrics.write("demo.events.emitted", 1, { source: "manual" }); + await ctx.telemetry.track("demo_event", { + source: "manual", + has_company: Boolean(companyId), + }); + pushRecord({ + level: "info", + source: "telemetry", + message: "Tracked plugin telemetry event demo_event", + data: { companyId }, + }); return { ok: true, message }; }); diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index 83724ee0..d6424921 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -312,6 +312,7 @@ Declare in `manifest.capabilities`. Grouped by scope: | | `issue.comments.create` | | | `activity.log.write` | | | `metrics.write` | +| | `telemetry.track` | | **Instance** | `instance.settings.register` | | | `plugin.state.read` | | | `plugin.state.write` | diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index c976fe8f..8b98cc87 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -135,6 +135,11 @@ export interface HostServices { write(params: WorkerToHostMethods["metrics.write"][0]): Promise; }; + /** Provides `telemetry.track`. */ + telemetry: { + track(params: WorkerToHostMethods["telemetry.track"][0]): Promise; + }; + /** Provides `log`. */ logger: { log(params: WorkerToHostMethods["log"][0]): Promise; @@ -284,6 +289,9 @@ const METHOD_CAPABILITY_MAP: Record { + return services.telemetry.track(params); + }), + // Logger "log": gated("log", async (params) => { return services.logger.log(params); diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index abe41e35..69e22c4f 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -182,6 +182,7 @@ export type { PluginStreamsClient, PluginToolsClient, PluginMetricsClient, + PluginTelemetryClient, PluginLogger, } from "./types.js"; diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index a26bf5dc..b77a0e75 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -519,6 +519,12 @@ export interface WorkerToHostMethods { result: void, ]; + // Telemetry + "telemetry.track": [ + params: { eventName: string; dimensions?: Record }, + result: void, + ]; + // Logger "log": [ params: { level: "info" | "warn" | "error" | "debug"; message: string; meta?: Record }, diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index d57dc7cc..83fbfb5b 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -71,6 +71,7 @@ export interface TestHarness { logs: TestHarnessLogEntry[]; activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record }>; metrics: Array<{ name: string; value: number; tags?: Record }>; + telemetry: Array<{ eventName: string; dimensions?: Record }>; } type EventRegistration = { @@ -132,6 +133,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { const logs: TestHarnessLogEntry[] = []; const activity: TestHarness["activity"] = []; const metrics: TestHarness["metrics"] = []; + const telemetry: TestHarness["telemetry"] = []; const state = new Map(); const entities = new Map(); @@ -631,6 +633,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { metrics.push({ name, value, tags }); }, }, + telemetry: { + async track(eventName, dimensions) { + requireCapability(manifest, capabilitySet, "telemetry.track"); + telemetry.push({ eventName, dimensions }); + }, + }, logger: { info(message, meta) { logs.push({ level: "info", message, meta }); @@ -729,6 +737,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { logs, activity, metrics, + telemetry, }; return harness; diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 51824651..4b707e28 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -761,6 +761,28 @@ export interface PluginMetricsClient { write(name: string, value: number, tags?: Record): Promise; } +/** + * `ctx.telemetry` — emit plugin-scoped telemetry to the host's external + * telemetry pipeline. + * + * Requires `telemetry.track` capability. + */ +export interface PluginTelemetryClient { + /** + * Track a plugin telemetry event. + * + * The host prefixes the final event name as `plugin..` + * before forwarding it to the shared telemetry client. + * + * @param eventName - Bare plugin event slug (for example `"sync_completed"`) + * @param dimensions - Optional structured dimensions + */ + track( + eventName: string, + dimensions?: Record, + ): Promise; +} + /** * `ctx.companies` — read company metadata. * @@ -1156,6 +1178,9 @@ export interface PluginContext { /** Write plugin metrics. Requires `metrics.write`. */ metrics: PluginMetricsClient; + /** Emit plugin-scoped external telemetry. Requires `telemetry.track`. */ + telemetry: PluginTelemetryClient; + /** Structured logger. Output is captured and surfaced in the plugin health dashboard. */ logger: PluginLogger; } diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index 20ca02fc..a64d225a 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -793,6 +793,15 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }, }, + telemetry: { + async track( + eventName: string, + dimensions?: Record, + ): Promise { + await callHost("telemetry.track", { eventName, dimensions }); + }, + }, + logger: { info(message: string, meta?: Record): void { notifyHost("log", { level: "info", message, meta }); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 76a4434b..e385aca5 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -448,6 +448,7 @@ export const PLUGIN_CAPABILITIES = [ "agent.sessions.close", "activity.log.write", "metrics.write", + "telemetry.track", // Plugin State "plugin.state.read", "plugin.state.write", diff --git a/packages/shared/src/telemetry/types.ts b/packages/shared/src/telemetry/types.ts index cb0fed73..a8e3d4dc 100644 --- a/packages/shared/src/telemetry/types.ts +++ b/packages/shared/src/telemetry/types.ts @@ -33,4 +33,5 @@ export type TelemetryEventName = | "company.imported" | "agent.first_heartbeat" | "agent.task_completed" - | "error.handler_crash"; + | "error.handler_crash" + | `plugin.${string}`; diff --git a/server/src/__tests__/plugin-telemetry-bridge.test.ts b/server/src/__tests__/plugin-telemetry-bridge.test.ts new file mode 100644 index 00000000..367663d9 --- /dev/null +++ b/server/src/__tests__/plugin-telemetry-bridge.test.ts @@ -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); + }); +}); diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts index 77e23231..0d4bb2a7 100644 --- a/server/src/services/plugin-capability-validator.ts +++ b/server/src/services/plugin-capability-validator.ts @@ -68,6 +68,7 @@ const OPERATION_CAPABILITIES: Record = { "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"], diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 9775a62d..4d487552 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -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;