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:
parent
53dbcd185e
commit
af844b778e
14 changed files with 209 additions and 1 deletions
|
|
@ -41,6 +41,7 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||||
"goals.update",
|
"goals.update",
|
||||||
"activity.log.write",
|
"activity.log.write",
|
||||||
"metrics.write",
|
"metrics.write",
|
||||||
|
"telemetry.track",
|
||||||
"plugin.state.read",
|
"plugin.state.read",
|
||||||
"plugin.state.write",
|
"plugin.state.write",
|
||||||
"events.subscribe",
|
"events.subscribe",
|
||||||
|
|
|
||||||
|
|
@ -405,6 +405,16 @@ async function registerActionHandlers(ctx: PluginContext): Promise<void> {
|
||||||
data: { companyId },
|
data: { companyId },
|
||||||
});
|
});
|
||||||
await ctx.metrics.write("demo.events.emitted", 1, { source: "manual" });
|
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 };
|
return { ok: true, message };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -312,6 +312,7 @@ Declare in `manifest.capabilities`. Grouped by scope:
|
||||||
| | `issue.comments.create` |
|
| | `issue.comments.create` |
|
||||||
| | `activity.log.write` |
|
| | `activity.log.write` |
|
||||||
| | `metrics.write` |
|
| | `metrics.write` |
|
||||||
|
| | `telemetry.track` |
|
||||||
| **Instance** | `instance.settings.register` |
|
| **Instance** | `instance.settings.register` |
|
||||||
| | `plugin.state.read` |
|
| | `plugin.state.read` |
|
||||||
| | `plugin.state.write` |
|
| | `plugin.state.write` |
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,11 @@ export interface HostServices {
|
||||||
write(params: WorkerToHostMethods["metrics.write"][0]): Promise<void>;
|
write(params: WorkerToHostMethods["metrics.write"][0]): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Provides `telemetry.track`. */
|
||||||
|
telemetry: {
|
||||||
|
track(params: WorkerToHostMethods["telemetry.track"][0]): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
/** Provides `log`. */
|
/** Provides `log`. */
|
||||||
logger: {
|
logger: {
|
||||||
log(params: WorkerToHostMethods["log"][0]): Promise<void>;
|
log(params: WorkerToHostMethods["log"][0]): Promise<void>;
|
||||||
|
|
@ -284,6 +289,9 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
||||||
// Metrics
|
// Metrics
|
||||||
"metrics.write": "metrics.write",
|
"metrics.write": "metrics.write",
|
||||||
|
|
||||||
|
// Telemetry
|
||||||
|
"telemetry.track": "telemetry.track",
|
||||||
|
|
||||||
// Logger — always allowed
|
// Logger — always allowed
|
||||||
"log": null,
|
"log": null,
|
||||||
|
|
||||||
|
|
@ -447,6 +455,11 @@ export function createHostClientHandlers(
|
||||||
return services.metrics.write(params);
|
return services.metrics.write(params);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Telemetry
|
||||||
|
"telemetry.track": gated("telemetry.track", async (params) => {
|
||||||
|
return services.telemetry.track(params);
|
||||||
|
}),
|
||||||
|
|
||||||
// Logger
|
// Logger
|
||||||
"log": gated("log", async (params) => {
|
"log": gated("log", async (params) => {
|
||||||
return services.logger.log(params);
|
return services.logger.log(params);
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,7 @@ export type {
|
||||||
PluginStreamsClient,
|
PluginStreamsClient,
|
||||||
PluginToolsClient,
|
PluginToolsClient,
|
||||||
PluginMetricsClient,
|
PluginMetricsClient,
|
||||||
|
PluginTelemetryClient,
|
||||||
PluginLogger,
|
PluginLogger,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -519,6 +519,12 @@ export interface WorkerToHostMethods {
|
||||||
result: void,
|
result: void,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Telemetry
|
||||||
|
"telemetry.track": [
|
||||||
|
params: { eventName: string; dimensions?: Record<string, string | number | boolean> },
|
||||||
|
result: void,
|
||||||
|
];
|
||||||
|
|
||||||
// Logger
|
// Logger
|
||||||
"log": [
|
"log": [
|
||||||
params: { level: "info" | "warn" | "error" | "debug"; message: string; meta?: Record<string, unknown> },
|
params: { level: "info" | "warn" | "error" | "debug"; message: string; meta?: Record<string, unknown> },
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ export interface TestHarness {
|
||||||
logs: TestHarnessLogEntry[];
|
logs: TestHarnessLogEntry[];
|
||||||
activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record<string, unknown> }>;
|
activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record<string, unknown> }>;
|
||||||
metrics: Array<{ name: string; value: number; tags?: Record<string, string> }>;
|
metrics: Array<{ name: string; value: number; tags?: Record<string, string> }>;
|
||||||
|
telemetry: Array<{ eventName: string; dimensions?: Record<string, string | number | boolean> }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventRegistration = {
|
type EventRegistration = {
|
||||||
|
|
@ -132,6 +133,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||||
const logs: TestHarnessLogEntry[] = [];
|
const logs: TestHarnessLogEntry[] = [];
|
||||||
const activity: TestHarness["activity"] = [];
|
const activity: TestHarness["activity"] = [];
|
||||||
const metrics: TestHarness["metrics"] = [];
|
const metrics: TestHarness["metrics"] = [];
|
||||||
|
const telemetry: TestHarness["telemetry"] = [];
|
||||||
|
|
||||||
const state = new Map<string, unknown>();
|
const state = new Map<string, unknown>();
|
||||||
const entities = new Map<string, PluginEntityRecord>();
|
const entities = new Map<string, PluginEntityRecord>();
|
||||||
|
|
@ -631,6 +633,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||||
metrics.push({ name, value, tags });
|
metrics.push({ name, value, tags });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
telemetry: {
|
||||||
|
async track(eventName, dimensions) {
|
||||||
|
requireCapability(manifest, capabilitySet, "telemetry.track");
|
||||||
|
telemetry.push({ eventName, dimensions });
|
||||||
|
},
|
||||||
|
},
|
||||||
logger: {
|
logger: {
|
||||||
info(message, meta) {
|
info(message, meta) {
|
||||||
logs.push({ level: "info", message, meta });
|
logs.push({ level: "info", message, meta });
|
||||||
|
|
@ -729,6 +737,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||||
logs,
|
logs,
|
||||||
activity,
|
activity,
|
||||||
metrics,
|
metrics,
|
||||||
|
telemetry,
|
||||||
};
|
};
|
||||||
|
|
||||||
return harness;
|
return harness;
|
||||||
|
|
|
||||||
|
|
@ -761,6 +761,28 @@ export interface PluginMetricsClient {
|
||||||
write(name: string, value: number, tags?: Record<string, string>): Promise<void>;
|
write(name: string, value: number, tags?: Record<string, string>): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `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.<pluginId>.<eventName>`
|
||||||
|
* 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<string, string | number | boolean>,
|
||||||
|
): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `ctx.companies` — read company metadata.
|
* `ctx.companies` — read company metadata.
|
||||||
*
|
*
|
||||||
|
|
@ -1156,6 +1178,9 @@ export interface PluginContext {
|
||||||
/** Write plugin metrics. Requires `metrics.write`. */
|
/** Write plugin metrics. Requires `metrics.write`. */
|
||||||
metrics: PluginMetricsClient;
|
metrics: PluginMetricsClient;
|
||||||
|
|
||||||
|
/** Emit plugin-scoped external telemetry. Requires `telemetry.track`. */
|
||||||
|
telemetry: PluginTelemetryClient;
|
||||||
|
|
||||||
/** Structured logger. Output is captured and surfaced in the plugin health dashboard. */
|
/** Structured logger. Output is captured and surfaced in the plugin health dashboard. */
|
||||||
logger: PluginLogger;
|
logger: PluginLogger;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -793,6 +793,15 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
telemetry: {
|
||||||
|
async track(
|
||||||
|
eventName: string,
|
||||||
|
dimensions?: Record<string, string | number | boolean>,
|
||||||
|
): Promise<void> {
|
||||||
|
await callHost("telemetry.track", { eventName, dimensions });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
logger: {
|
logger: {
|
||||||
info(message: string, meta?: Record<string, unknown>): void {
|
info(message: string, meta?: Record<string, unknown>): void {
|
||||||
notifyHost("log", { level: "info", message, meta });
|
notifyHost("log", { level: "info", message, meta });
|
||||||
|
|
|
||||||
|
|
@ -448,6 +448,7 @@ export const PLUGIN_CAPABILITIES = [
|
||||||
"agent.sessions.close",
|
"agent.sessions.close",
|
||||||
"activity.log.write",
|
"activity.log.write",
|
||||||
"metrics.write",
|
"metrics.write",
|
||||||
|
"telemetry.track",
|
||||||
// Plugin State
|
// Plugin State
|
||||||
"plugin.state.read",
|
"plugin.state.read",
|
||||||
"plugin.state.write",
|
"plugin.state.write",
|
||||||
|
|
|
||||||
|
|
@ -33,4 +33,5 @@ export type TelemetryEventName =
|
||||||
| "company.imported"
|
| "company.imported"
|
||||||
| "agent.first_heartbeat"
|
| "agent.first_heartbeat"
|
||||||
| "agent.task_completed"
|
| "agent.task_completed"
|
||||||
| "error.handler_crash";
|
| "error.handler_crash"
|
||||||
|
| `plugin.${string}`;
|
||||||
|
|
|
||||||
114
server/src/__tests__/plugin-telemetry-bridge.test.ts
Normal file
114
server/src/__tests__/plugin-telemetry-bridge.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -68,6 +68,7 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
||||||
"issue.comments.create": ["issue.comments.create"],
|
"issue.comments.create": ["issue.comments.create"],
|
||||||
"activity.log": ["activity.log.write"],
|
"activity.log": ["activity.log.write"],
|
||||||
"metrics.write": ["metrics.write"],
|
"metrics.write": ["metrics.write"],
|
||||||
|
"telemetry.track": ["telemetry.track"],
|
||||||
|
|
||||||
// Plugin state operations
|
// Plugin state operations
|
||||||
"plugin.state.get": ["plugin.state.read"],
|
"plugin.state.get": ["plugin.state.read"],
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import { request as httpRequest } from "node:http";
|
||||||
import { request as httpsRequest } from "node:https";
|
import { request as httpsRequest } from "node:https";
|
||||||
import { isIP } from "node:net";
|
import { isIP } from "node:net";
|
||||||
import { logger } from "../middleware/logger.js";
|
import { logger } from "../middleware/logger.js";
|
||||||
|
import { getTelemetryClient } from "../telemetry.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// SSRF protection for plugin HTTP fetch
|
// 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. */
|
/** Only these protocols are allowed for plugin HTTP requests. */
|
||||||
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
|
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,
|
* 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: {
|
logger: {
|
||||||
async log(params) {
|
async log(params) {
|
||||||
const { level, meta } = params;
|
const { level, meta } = params;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue