diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d57b69d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +# AGENTS.md + +## Cursor Cloud specific instructions + +### Overview + +This is `langfuse-cli` — a CLI tool for interacting with the Langfuse API. It wraps the [`specli`](https://www.npmjs.com/package/specli) library which auto-generates CLI commands from a bundled OpenAPI specification (`openapi.yml`). + +### Prerequisites + +- **Bun** — required for build scripts (`bun run build`, `bun run patch-openapi`). +- **Node.js >= 20** — runtime for the built CLI (`bin/langfuse.mjs`). + +### Key commands + +| Task | Command | +|---|---| +| Install deps | `bun install` | +| Build | `bun run build` (fetches latest OpenAPI spec + bundles `src/cli.ts` → `dist/cli.js`) | +| Run CLI | `node bin/langfuse.mjs` | +| Patch OpenAPI only | `bun run patch-openapi` | +| Refetch OpenAPI | `bun run refetch-openapi` | + +### Development notes + +- There are no automated tests, linter, or formatter configured in this project. +- The build step (`bun run build`) fetches the OpenAPI spec from `https://cloud.langfuse.com/generated/api/openapi.yml` — it requires network access. +- To test the CLI against the Langfuse API you need `LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY` env vars (or pass `--public-key` / `--secret-key` flags). See `.env.default` for local dev defaults. +- The `--curl` flag on any API command previews the curl command without executing it, useful for verifying command construction without credentials. +- The project has no lockfile committed; `bun install` generates `bun.lock` locally. diff --git a/README.md b/README.md index ec03a91..e2e3b0e 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,12 @@ langfuse api score-v2s get-scores --limit 20 ## Agent Usage +Install the official Langfuse AI skill: + +```sh +npx skills add langfuse/skills --skill "langfuse" +``` + A skill file is included for teaching AI agents how to use the CLI. Print it with: ```sh @@ -85,6 +91,12 @@ langfuse get-skill Pipe it into an agent's context or include it in a system prompt. +## Tracing + +This CLI is instrumented with Langfuse tracing when credentials are configured (`LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, and optional `LANGFUSE_HOST`). + +It creates traces for each command and nested spans for API spec loading/help/execution, while only recording safe command metadata (not secret flags). + ## API Reference See the full [Langfuse API Reference](https://api.reference.langfuse.com/). diff --git a/package.json b/package.json index 215c920..e8246d1 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ ], "type": "module", "bin": { - "langfuse": "./bin/langfuse.mjs" + "langfuse": "./bin/langfuse.mjs", + "langfuse-cli": "./bin/langfuse.mjs" }, "files": [ "bin", @@ -34,6 +35,11 @@ "prepublishOnly": "rm -rf dist && bun run build" }, "dependencies": { + "@langfuse/otel": "^5.3.0", + "@langfuse/tracing": "^5.3.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-trace-otlp-http": "^0.218.0", + "@opentelemetry/sdk-trace-node": "^2.7.1", "specli": "^0.0.39" }, "engines": { diff --git a/src/cli.ts b/src/cli.ts index 3b6b677..8ded6d7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,11 @@ const LANGFUSE_FLAGS = new Set([ ]); const LANGFUSE_BOOL_FLAGS = new Set(["--refetch-api-spec"]); +type TracingRuntime = { + startActiveObservation: typeof import("@langfuse/tracing").startActiveObservation; + updateActiveObservation: typeof import("@langfuse/tracing").updateActiveObservation; +}; + function loadEnvFile(filePath: string): void { const content = readFileSync(filePath, "utf-8"); for (const line of content.split("\n")) { @@ -64,6 +69,111 @@ async function getSpecText(params: { return readFileSync(specPath, "utf-8"); } +function getErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +async function runTracedStep(params: { + tracing: TracingRuntime | null; + name: string; + input?: unknown; + fn: () => Promise; +}): Promise { + const { tracing, name, input, fn } = params; + if (!tracing) return fn(); + + return tracing.startActiveObservation(name, async () => { + const startedAt = Date.now(); + if (input !== undefined) { + tracing.updateActiveObservation({ input }); + } + try { + const result = await fn(); + tracing.updateActiveObservation({ + output: { + status: "ok", + durationMs: Date.now() - startedAt, + }, + }); + return result; + } catch (error) { + tracing.updateActiveObservation({ + level: "ERROR", + statusMessage: getErrorMessage(error), + output: { + status: "error", + durationMs: Date.now() - startedAt, + }, + }); + throw error; + } + }); +} + +async function withLangfuseTracing(params: { + publicKey: string | undefined; + secretKey: string | undefined; + host: string; + command: string; + fn: (tracing: TracingRuntime | null) => Promise; +}): Promise { + const { publicKey, secretKey, host, command, fn } = params; + if (!publicKey || !secretKey) { + return fn(null); + } + + const [{ LangfuseSpanProcessor }, tracingModule, { NodeTracerProvider }] = + await Promise.all([ + import("@langfuse/otel"), + import("@langfuse/tracing"), + import("@opentelemetry/sdk-trace-node"), + ]); + + const provider = new NodeTracerProvider({ + spanProcessors: [ + new LangfuseSpanProcessor({ + publicKey, + secretKey, + baseUrl: host, + }), + ], + }); + provider.register(); + tracingModule.setLangfuseTracerProvider(provider); + + const tracing: TracingRuntime = { + startActiveObservation: tracingModule.startActiveObservation, + updateActiveObservation: tracingModule.updateActiveObservation, + }; + + try { + return await tracingModule.propagateAttributes( + { + traceName: `langfuse-cli.${command}`, + metadata: { + command, + runtime: "langfuse-cli", + }, + tags: ["langfuse-cli", "cli"], + }, + () => + runTracedStep({ + tracing, + name: "langfuse-cli.command", + input: { command }, + fn: () => fn(tracing), + }), + ); + } finally { + try { + await provider.shutdown(); + } finally { + tracingModule.setLangfuseTracerProvider(null); + } + } +} + export async function run(argv: string[]): Promise { const extracted: Record = {}; const boolFlags: Record = {}; @@ -102,20 +212,36 @@ export async function run(argv: string[]): Promise { // First positional arg determines the subcommand const subcommand = passthrough[2]; + const commandForTracing = subcommand ?? "help"; - if (subcommand === "api") { - passthrough.splice(2, 1); - return runApi({ passthrough, boolFlags, publicKey, secretKey, host }); - } + await withLangfuseTracing({ + publicKey, + secretKey, + host, + command: commandForTracing, + fn: async (tracing) => { + if (subcommand === "api") { + passthrough.splice(2, 1); + return runApi({ + passthrough, + boolFlags, + publicKey, + secretKey, + host, + tracing, + }); + } - if (subcommand === "get-skill") { - const skillPath = join(__dirname, "..", "skill", "langfuse-cli.md"); - process.stdout.write(readFileSync(skillPath, "utf-8")); - return; - } + if (subcommand === "get-skill") { + const skillPath = join(__dirname, "..", "skill", "langfuse-cli.md"); + process.stdout.write(readFileSync(skillPath, "utf-8")); + return; + } - // Show help for anything else (no args, --help, -h, unknown command) - printHelp(); + // Show help for anything else (no args, --help, -h, unknown command) + printHelp(); + }, + }); } function printHelp(): void { @@ -196,12 +322,23 @@ async function runApi(params: { publicKey: string | undefined; secretKey: string | undefined; host: string; + tracing: TracingRuntime | null; }): Promise { - const { passthrough, boolFlags, publicKey, secretKey, host } = params; + const { passthrough, boolFlags, publicKey, secretKey, host, tracing } = params; + const apiResource = passthrough[2] ?? "unknown-resource"; + const apiAction = passthrough[3] ?? "help-action"; - const specText = await getSpecText({ - refetch: boolFlags["refetch-api-spec"] ?? false, - host, + const specText = await runTracedStep({ + tracing, + name: "langfuse-cli.api.load-spec", + input: { + refetchApiSpec: boolFlags["refetch-api-spec"] ?? false, + }, + fn: () => + getSpecText({ + refetch: boolFlags["refetch-api-spec"] ?? false, + host, + }), }); // Intercept help: no args, --help, or -h @@ -210,7 +347,17 @@ async function runApi(params: { args.length === 0 || (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) ) { - printApiHelp(await getResources(specText)); + await runTracedStep({ + tracing, + name: "langfuse-cli.api.help", + input: { + resource: apiResource, + action: apiAction, + }, + fn: async () => { + printApiHelp(await getResources(specText)); + }, + }); return; } @@ -220,10 +367,20 @@ async function runApi(params: { if (secretKey) inject.push("--password", secretKey); specliArgv.splice(2, 0, ...inject); - const main = await loadMain(); - await main(specliArgv, { - cliName: "langfuse api", - auth: "BasicAuth", - embeddedSpecText: specText, + await runTracedStep({ + tracing, + name: "langfuse-cli.api.execute", + input: { + resource: apiResource, + action: apiAction, + }, + fn: async () => { + const main = await loadMain(); + await main(specliArgv, { + cliName: "langfuse api", + auth: "BasicAuth", + embeddedSpecText: specText, + }); + }, }); }