Files
cline/sdk/examples/plugins/telemetry.ts
T
BeeandClaude Fable 5 12404f0a4b fix(core): add a plugin telemetry bridge (#12741)
* fix(core): add a plugin telemetry bridge

* fix(core): address plugin telemetry bridge review feedback

- Sanitization fallback now covers the whole executeTool IPC payload:
  `input` can be rewritten by beforeTool hooks or programmatic callers,
  so a non-serializable input degrades gracefully like the context does.
- The sandbox only offers ctx.telemetry when the host actually has a
  telemetry service (new PluginSandboxOptions.telemetryAvailable, derived
  from options.telemetry in the config loader), so feature-detecting
  ctx.telemetry means "someone is listening" in both execution modes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): plugin telemetry review round 2 — timer leak and setup-time fallback

- SubprocessSandbox.call: a synchronous child.send() throw (cyclic payload)
  left the pending timeout timer armed; it later fired and shut the sandbox
  down, killing unrelated in-flight calls. Cancel the pending entry and
  reject with the original error so serialization failures stay classifiable.
- plugin_telemetry events emitted during plugin setup() arrive before the
  session is registered, so the session-config lookup missed and setup-time
  telemetry was silently dropped. Route through a fallback telemetry service
  (extensionContext/local config/host default), mirroring handlePluginLog's
  fallback logger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): classify BigInt IPC serialization errors for the sandbox fallback

Bun ("cannot serialize BigInt") and Node ("Do not know how to serialize a
BigInt") raise messages that did not match the cyclic/circular predicate, so
a bigint smuggled into tool input or context by a hook or programmatic caller
rethrew instead of retrying with the JSON-safe clone — which already drops
bigint leaves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:44:25 -07:00

171 lines
6.1 KiB
TypeScript

/**
* Telemetry & Logger Plugin Example
*
* The reference example for observability from a plugin. Shows how — and how
* NOT — to use the two host-provided observability channels on the setup
* context:
*
* ctx.logger — structured, session-scoped logging for humans debugging a
* session. Goes to the host's log sink (e.g. the CLI log
* file), tagged with your plugin name.
* ctx.telemetry — aggregated product/ops signals for dashboards. Goes to
* the host's telemetry service (events + metrics).
*
* The contract, in one place:
*
* 1. FEATURE-DETECT BOTH. Each is optional — `ctx.logger?.log(...)`,
* `ctx.telemetry?.capture(...)`. They are undefined when the host does
* not provide them; your plugin must work without them.
*
* 2. PLAIN DATA ONLY. Sandboxed plugins run in a separate process; logger
* and telemetry calls are forwarded to the host over JSON IPC. Pass
* strings, numbers, and booleans — never sockets, clients, or other live
* objects. (One exception: `logger` metadata may include an `error`
* value that is an Error instance — the bridge serializes its
* name/message/stack for you.)
*
* 3. NAMESPACING IS AUTOMATIC. The host prefixes every plugin event and
* metric with `plugin.` and stamps a `plugin_name` property/attribute,
* so plugins cannot impersonate first-party events. Emit short local
* names ("cache_refresh"), not pre-namespaced ones.
*
* 4. OPT-OUT IS HANDLED HOST-SIDE. When the user disabled telemetry, the
* host drops forwarded events. In the sandbox `isEnabled()` always
* reports `true` (the bridge is stateless), so do NOT gate expensive
* property computation on it — keep properties cheap to build.
*
* 5. LOW CARDINALITY, NO CONTENT. Telemetry properties should be statuses,
* names of known presets, durations, counts. Never user prompts, file
* contents, tool output, or anything secret — that belongs nowhere, and
* free-form text does not aggregate anyway. Detailed context goes to the
* logger instead (which stays on the user's machine).
*
* CLI usage:
* cline plugin install https://github.com/cline/cline/blob/main/sdk/examples/plugins/telemetry.ts --cwd .
* cline -i "Roll a die a few times and tell me your best roll."
*/
import {
type AgentPlugin,
type BasicLogger,
createTool,
type ITelemetryService,
} from "@cline/core";
// ---------------------------------------------------------------------------
// Plugin-level state
//
// setup() receives the logger and telemetry handles; hooks and background
// work do not. Capture them once at module level. If your host runs multiple
// sessions in one process, key any *data* by ctx.session?.sessionId — the
// handles themselves are safe to share.
// ---------------------------------------------------------------------------
let logger: BasicLogger | undefined;
let telemetry: ITelemetryService | undefined;
let rollCount = 0;
let bestRoll = 0;
const plugin: AgentPlugin = {
name: "telemetry-demo",
manifest: { capabilities: ["tools", "hooks"] },
setup(api, ctx) {
logger = ctx.logger;
telemetry = ctx.telemetry;
// Logger: human-facing, high-detail, session-scoped. The host tags
// entries with this plugin's name; metadata is structured, not string
// interpolation, so log processors can filter on it.
logger?.log("telemetry-demo setup", {
sessionId: ctx.session?.sessionId,
hasTelemetry: Boolean(telemetry),
});
// Telemetry event: aggregated, low-cardinality. Arrives at the host as
// `plugin.demo_setup` with plugin_name/session_id stamped on.
telemetry?.capture({
event: "demo_setup",
properties: { has_workspace: Boolean(ctx.workspaceInfo?.rootPath) },
});
api.registerTool(
createTool({
name: "roll_die",
description:
"Roll a six-sided die and return the result. Demonstrates plugin telemetry and logging.",
inputSchema: {
type: "object",
properties: {
sides: {
type: "number",
description: "Number of sides (default 6).",
},
},
required: [],
},
execute: async (input: unknown) => {
const { sides = 6 } = (input ?? {}) as { sides?: number };
if (!Number.isInteger(sides) || sides < 2 || sides > 1000) {
// Failure pattern: log the details for the user's log file,
// count the failure class (not the message!) in telemetry,
// and return a structured error for the model.
// `error` is optional on BasicLogger — hence `?.` twice.
logger?.error?.("roll_die rejected invalid input", {
sides,
});
telemetry?.recordCounter("demo.tool_errors", 1, {
tool_name: "roll_die",
reason: "invalid_sides",
});
return { error: "sides must be an integer between 2 and 1000" };
}
const roll = 1 + Math.floor(Math.random() * sides);
rollCount += 1;
bestRoll = Math.max(bestRoll, roll);
// debug-level: noisy per-call detail, invisible unless the
// host's log level includes debug.
logger?.debug("roll_die rolled", { roll, sides, rollCount });
// Metrics: counter for volume, histogram for distributions,
// gauge for point-in-time values. Attribute values must stay
// low-cardinality — `sides` is fine, a user string is not.
telemetry?.recordCounter("demo.rolls", 1, { sides });
telemetry?.recordHistogram("demo.roll_value", roll, { sides });
telemetry?.recordGauge("demo.best_roll", bestRoll);
return { roll, sides };
},
}),
);
},
hooks: {
afterRun({ result }) {
// End-of-run summary event. `captureRequired` bypasses sampling for
// events that must not be dropped (billing-grade); plain `capture`
// is right for almost everything, including this.
telemetry?.capture({
event: "demo_run_completed",
properties: {
status: result.status,
iterations: result.iterations,
rolls: rollCount,
best_roll: bestRoll,
},
});
logger?.log("telemetry-demo run completed", {
status: result.status,
rolls: rollCount,
});
},
},
};
export { plugin };
export default plugin;