mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
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
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"],
|
||||
"activity.log": ["activity.log.write"],
|
||||
"metrics.write": ["metrics.write"],
|
||||
"telemetry.track": ["telemetry.track"],
|
||||
|
||||
// Plugin state operations
|
||||
"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 { 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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue