Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
"name": "langfuse-observability",
"displayName": "Langfuse Observability",
"version": "0.1.0",
"description": "Trace Cursor agent sessions to Langfuse: prompts, agent turns, model generations, tool calls, subagents and token usage.",
"description": "Trace Cursor agent sessions to Langfuse and evaluate agent performance and quality: prompts, agent turns, model generations, tool calls, subagents and token usage. Tracing starts after a project API key pair is saved in ~/.cursor/langfuse.json.",
"rules": "./rules/",
"commands": "./commands/",
"author": {
"name": "Langfuse",
"email": "support@langfuse.com"
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
node_modules/
*.log
.DS_Store
.pnpm-store/

# Editing-tool scratch: Claude Code tracks its writes here and recreates the
# directory whenever the repo is edited with it. Never part of the plugin.
Expand Down
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
node_modules
# Cursor rule files. Prettier has no parser for this extension.
*.mdc
.claude
dist
pnpm-lock.yaml
Expand Down
245 changes: 138 additions & 107 deletions README.md

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions commands/langfuse-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
name: langfuse-setup
description: Create ~/.cursor/langfuse.json so Langfuse tracing can start
---

The user wants Langfuse tracing turned on. Do these steps in order.

1. Create `~/.cursor/langfuse.json` with mode 600 when the file is missing. Leave the fields empty so tracing stays off until the user sets the keys and the host for their region:
Comment thread
hande-k marked this conversation as resolved.

```bash
mkdir -p ~/.cursor
cat > ~/.cursor/langfuse.json <<'EOF'
{
"publicKey": "",
"secretKey": "",
"baseUrl": ""
}
EOF
chmod 600 ~/.cursor/langfuse.json
```

The file is done when it exists and its mode is 600. Do not overwrite a file that already has keys.

2. Tell the user, briefly:

Create a project at https://langfuse.com/cloud if you don't have one. In the project, open Settings → API Keys and copy the public key, secret key, and base URL for your region.

Self-hosted: follow https://langfuse.com/self-hosting (Langfuse v4) and use your instance URL as the base URL.

Give them a clickable link to the absolute path of `~/.cursor/langfuse.json` and ask them to paste the three values there. Leave the secret key out of the chat.

This step is done when the user has been told to edit that file.

3. When the public key, secret key, and base URL are all set, find `dist/index.mjs` under `~/.cursor/plugins/cache/cursor-public/langfuse-observability/` or `~/.cursor/plugins/local/langfuse-observability/` and run `node <that file> status`. Report the project name from that output. Do not print the keys.
23 changes: 14 additions & 9 deletions dist/cli-CCufOVGz.mjs → dist/cli-DHWpcEEa.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { r as asString, t as PLUGIN_VERSION } from "./version-ZGirXdLt.mjs";
import { r as getConfig, t as ConversationStore } from "./state-BCqq8k6q.mjs";
import { r as asString, t as PLUGIN_VERSION } from "./version-B2EdcJjP.mjs";
import { r as getConfig, t as ConversationStore } from "./state-DHhYKuAQ.mjs";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import * as fs from "node:fs";
import { fileURLToPath } from "node:url";

//#region src/cli.ts
Expand Down Expand Up @@ -176,18 +176,23 @@ async function runSetup(argv) {
const configFile = path.join(scopeDir, ".cursor", "langfuse.json");
const publicKey = (asString(args.get("public-key")) ?? process.env.LANGFUSE_PUBLIC_KEY) || void 0;
const secretKey = (asString(args.get("secret-key")) ?? process.env.LANGFUSE_SECRET_KEY) || void 0;
const baseUrl = (asString(args.get("base-url")) ?? process.env.LANGFUSE_BASE_URL ?? "https://cloud.langfuse.com").replace(/\/+$/, "");
const baseUrl = (asString(args.get("base-url")) ?? process.env.LANGFUSE_BASE_URL)?.replace(/\/+$/, "");
const environment = asString(args.get("environment")) ?? process.env.LANGFUSE_TRACING_ENVIRONMENT;
if (!publicKey || !secretKey) {
if (!publicKey || !secretKey || !baseUrl) {
process.stdout.write([
"Langfuse setup for Cursor",
"",
"Pass your project keys, or export LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY first:",
"Pass your project keys and the host for your region, or export LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, and LANGFUSE_BASE_URL first:",
"",
" langfuse-cursor-hook setup --public-key pk-lf-… --secret-key sk-lf-…",
" langfuse-cursor-hook setup --public-key pk-lf-… --secret-key sk-lf-… --base-url <region host>",
"",
"Options:",
" --base-url <url> Langfuse host (default https://cloud.langfuse.com, the EU region)",
" --base-url <url> Langfuse host for your region. Required.",
" Europe: https://cloud.langfuse.com",
" US: https://us.cloud.langfuse.com",
" US HIPAA: https://hipaa.cloud.langfuse.com",
" Japan: https://jp.cloud.langfuse.com",
" Self-hosted: your Langfuse v4 instance URL (https://langfuse.com/self-hosting)",
" --environment <name> Langfuse environment label for the traces",
" --project <path> Write the keys into <path>/.cursor/langfuse.json instead of ~/.cursor",
" --hooks Also register the hooks in hooks.json (use this when the plugin",
Expand Down Expand Up @@ -241,7 +246,7 @@ async function runStatus(argv) {
` bundle ${path.join(pluginRoot(), "dist", "index.mjs")}`,
` node ${process.version} (${process.execPath})`,
` tracing ${config.enabled ? "on" : "off"}`,
` host ${config.base_url}`,
` host ${config.base_url || "—"}`,
` public key ${mask(config.public_key)}`,
` secret key ${config.secret_key ? "set" : "—"}`,
` user ${config.user_id ?? "(Cursor account email)"}`,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { i as __require, r as __commonJSMin } from "./export-ClA3tmiD.mjs";
import { i as __require, r as __commonJSMin } from "./export-C23vuE3p.mjs";

//#region node_modules/.pnpm/@opentelemetry+resources@2.11.0_@opentelemetry+api@1.9.1/node_modules/@opentelemetry/resources/build/src/detectors/platform/node/machine-id/execAsync.js
var require_execAsync = /* @__PURE__ */ __commonJSMin(((exports) => {
Expand Down
12 changes: 6 additions & 6 deletions dist/export-ClA3tmiD.mjs → dist/export-C23vuE3p.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { _ as tryParseJson, a as clipText, c as guessTranscriptPath, d as modelParamsToRecord, f as parseTraceparent, g as traceIdFromSeed, h as toText, i as clipDeep, m as spanIdFromSeed, n as asNumber, r as asString, s as debugLog, t as PLUGIN_VERSION, u as isRecord } from "./version-ZGirXdLt.mjs";
import { _ as tryParseJson, a as clipText, c as guessTranscriptPath, d as modelParamsToRecord, f as parseTraceparent, g as traceIdFromSeed, h as toText, i as clipDeep, m as spanIdFromSeed, n as asNumber, r as asString, s as debugLog, t as PLUGIN_VERSION, u as isRecord } from "./version-B2EdcJjP.mjs";
import { n as createSecretMask, t as applyCapturePolicy } from "./privacy-BohI-nng.mjs";
import { createRequire } from "node:module";
import * as fs$1 from "node:fs";
Expand Down Expand Up @@ -22418,18 +22418,18 @@ var require_getMachineId = /* @__PURE__ */ __commonJSMin(((exports) => {
async function getMachineId() {
if (!getMachineIdImpl) switch (process$1.platform) {
case "darwin":
getMachineIdImpl = (await import("./getMachineId-darwin-BZ56p4cI.mjs").then((m) => /* @__PURE__ */ __toESM(m.default))).getMachineId;
getMachineIdImpl = (await import("./getMachineId-darwin-C1_rrTYb.mjs").then((m) => /* @__PURE__ */ __toESM(m.default))).getMachineId;
break;
case "linux":
getMachineIdImpl = (await import("./getMachineId-linux-EU52PRcu.mjs").then((m) => /* @__PURE__ */ __toESM(m.default))).getMachineId;
getMachineIdImpl = (await import("./getMachineId-linux-Bq-nvZqe.mjs").then((m) => /* @__PURE__ */ __toESM(m.default))).getMachineId;
break;
case "freebsd":
getMachineIdImpl = (await import("./getMachineId-bsd-C3dmanle.mjs").then((m) => /* @__PURE__ */ __toESM(m.default))).getMachineId;
getMachineIdImpl = (await import("./getMachineId-bsd-DsxAPNIB.mjs").then((m) => /* @__PURE__ */ __toESM(m.default))).getMachineId;
break;
case "win32":
getMachineIdImpl = (await import("./getMachineId-win-dSHQa_GJ.mjs").then((m) => /* @__PURE__ */ __toESM(m.default))).getMachineId;
getMachineIdImpl = (await import("./getMachineId-win-DMVzbVRQ.mjs").then((m) => /* @__PURE__ */ __toESM(m.default))).getMachineId;
break;
default: getMachineIdImpl = (await import("./getMachineId-unsupported-Dsuikc_w.mjs").then((m) => /* @__PURE__ */ __toESM(m.default))).getMachineId;
default: getMachineIdImpl = (await import("./getMachineId-unsupported-D33tvGXg.mjs").then((m) => /* @__PURE__ */ __toESM(m.default))).getMachineId;
}
return getMachineIdImpl();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { t as require_execAsync } from "./execAsync-loQMU1r9.mjs";
import { a as __toCommonJS, i as __require, n as init_esm, r as __commonJSMin, t as esm_exports } from "./export-ClA3tmiD.mjs";
import { t as require_execAsync } from "./execAsync-DOm5PR5p.mjs";
import { a as __toCommonJS, i as __require, n as init_esm, r as __commonJSMin, t as esm_exports } from "./export-C23vuE3p.mjs";

//#region node_modules/.pnpm/@opentelemetry+resources@2.11.0_@opentelemetry+api@1.9.1/node_modules/@opentelemetry/resources/build/src/detectors/platform/node/machine-id/getMachineId-bsd.js
var require_getMachineId_bsd = /* @__PURE__ */ __commonJSMin(((exports) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { t as require_execAsync } from "./execAsync-loQMU1r9.mjs";
import { a as __toCommonJS, n as init_esm, r as __commonJSMin, t as esm_exports } from "./export-ClA3tmiD.mjs";
import { t as require_execAsync } from "./execAsync-DOm5PR5p.mjs";
import { a as __toCommonJS, n as init_esm, r as __commonJSMin, t as esm_exports } from "./export-C23vuE3p.mjs";

//#region node_modules/.pnpm/@opentelemetry+resources@2.11.0_@opentelemetry+api@1.9.1/node_modules/@opentelemetry/resources/build/src/detectors/platform/node/machine-id/getMachineId-darwin.js
var require_getMachineId_darwin = /* @__PURE__ */ __commonJSMin(((exports) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { a as __toCommonJS, i as __require, n as init_esm, r as __commonJSMin, t as esm_exports } from "./export-ClA3tmiD.mjs";
import { a as __toCommonJS, i as __require, n as init_esm, r as __commonJSMin, t as esm_exports } from "./export-C23vuE3p.mjs";

//#region node_modules/.pnpm/@opentelemetry+resources@2.11.0_@opentelemetry+api@1.9.1/node_modules/@opentelemetry/resources/build/src/detectors/platform/node/machine-id/getMachineId-linux.js
var require_getMachineId_linux = /* @__PURE__ */ __commonJSMin(((exports) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { a as __toCommonJS, n as init_esm, r as __commonJSMin, t as esm_exports } from "./export-ClA3tmiD.mjs";
import { a as __toCommonJS, n as init_esm, r as __commonJSMin, t as esm_exports } from "./export-C23vuE3p.mjs";

//#region node_modules/.pnpm/@opentelemetry+resources@2.11.0_@opentelemetry+api@1.9.1/node_modules/@opentelemetry/resources/build/src/detectors/platform/node/machine-id/getMachineId-unsupported.js
var require_getMachineId_unsupported = /* @__PURE__ */ __commonJSMin(((exports) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { t as require_execAsync } from "./execAsync-loQMU1r9.mjs";
import { a as __toCommonJS, i as __require, n as init_esm, r as __commonJSMin, t as esm_exports } from "./export-ClA3tmiD.mjs";
import { t as require_execAsync } from "./execAsync-DOm5PR5p.mjs";
import { a as __toCommonJS, i as __require, n as init_esm, r as __commonJSMin, t as esm_exports } from "./export-C23vuE3p.mjs";

//#region node_modules/.pnpm/@opentelemetry+resources@2.11.0_@opentelemetry+api@1.9.1/node_modules/@opentelemetry/resources/build/src/detectors/platform/node/machine-id/getMachineId-win.js
var require_getMachineId_win = /* @__PURE__ */ __commonJSMin(((exports) => {
Expand Down
70 changes: 63 additions & 7 deletions dist/index.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
#!/usr/bin/env node
import { l as infoLog, o as configureLogging, p as readStdin, r as asString, s as debugLog, t as PLUGIN_VERSION } from "./version-ZGirXdLt.mjs";
import { n as disabledReason, r as getConfig, t as ConversationStore } from "./state-BCqq8k6q.mjs";
import { l as infoLog, o as configureLogging, p as readStdin, r as asString, s as debugLog, t as PLUGIN_VERSION } from "./version-B2EdcJjP.mjs";
import { n as disabledReason, r as getConfig, t as ConversationStore } from "./state-DHhYKuAQ.mjs";
import { t as applyCapturePolicy } from "./privacy-BohI-nng.mjs";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { pathToFileURL } from "node:url";

//#region src/index.ts
/**
Expand All @@ -15,6 +17,50 @@ import * as path from "node:path";
* `LANGFUSE_CURSOR_FAIL_ON_ERROR=true` surfaces them as hook failures instead,
* for testing.
*/
/**
* User-facing setup message for a new chat with no project API key.
*
* `sessionStart` is the hook Cursor documents as accepting `additional_context`.
* `beforeSubmitPrompt` can show `user_message` only when `continue` is false,
* which would discard the user's first prompt, so a missing key never blocks.
* An already-open chat does not receive `sessionStart`; `/langfuse-setup`
* covers that case. Keep the steps aligned with `commands/langfuse-setup.md`.
*
* The leading sentence is the signal `rules/langfuse-setup.mdc` matches.
* A kill switch or `"enabled": false` does not get this text: the user turned
* tracing off on purpose.
*
* Empty key and baseUrl fields keep tracing off. Placeholder strings like
* `pk-lf-...` would count as keys and the hint would never show again.
* baseUrl stays empty so the file does not pick a region for the user.
*/
function credentialsFile(home) {
return path.join(home, ".cursor", "langfuse.json");
}
function ensureCredentialsFile(home) {
const file = credentialsFile(home);
if (!fs.existsSync(file)) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify({
publicKey: "",
secretKey: "",
baseUrl: ""
}, null, 2)}\n`);
fs.chmodSync(file, 384);
}
return file;
}
function setupMessage(file) {
return [
"Langfuse tracing is not configured.",
"",
"Create a project at https://langfuse.com/cloud if you don't have one. In the project, open Settings → API Keys and copy the public key, secret key, and base URL for your region.",
"",
"Self-hosted: follow https://langfuse.com/self-hosting (Langfuse v4) and use your instance URL as the base URL.",
"",
`Paste the public key, secret key, and base URL into ${`[${file}](${pathToFileURL(file).href})`}.`
].join("\n");
}
/** Decisions the gates expect. Everything we do is observe-only, so always allow. */
const PASSTHROUGH = {
beforeSubmitPrompt: { continue: true },
Expand Down Expand Up @@ -50,7 +96,7 @@ async function exportAndClose(ctx, state, closedBy, stopPayload) {
const turnNumber = state.openTurn?.turnNumber ?? state.turnsCompleted + 1;
await store.withLock(async () => {
try {
const { exportTurn } = await import("./export-ClA3tmiD.mjs");
const { exportTurn } = await import("./export-C23vuE3p.mjs");
const result = await exportTurn({
config,
store,
Expand All @@ -77,7 +123,7 @@ async function exportAndClose(ctx, state, closedBy, stopPayload) {
}
});
}
async function handle(payload) {
async function handle(payload, response) {
const hook = payload.hook_event_name;
const workspaceRoot = Array.isArray(payload.workspace_roots) ? asString(payload.workspace_roots[0]) : void 0;
const config = getConfig({ workspaceRoot });
Expand All @@ -89,6 +135,16 @@ async function handle(payload) {
failOnError = config.fail_on_error;
if (!config.enabled) {
if (hook === "beforeSubmitPrompt") infoLog(`Tracing off: ${disabledReason(config, process.env)}`);
if (hook === "sessionStart" && disabledReason(config, process.env).startsWith("Langfuse config incomplete")) {
const home = process.env.HOME ?? os.homedir();
const file = credentialsFile(home);
try {
ensureCredentialsFile(home);
} catch (error) {
infoLog("could not create credentials file:", error);
}
response.additional_context = setupMessage(file);
}
return;
}
if (!RECORDED_HOOKS.has(hook) && hook !== "sessionStart") {
Expand Down Expand Up @@ -150,7 +206,7 @@ async function handle(payload) {
async function runHook() {
const subcommand = process.argv[2];
if (subcommand === "setup" || subcommand === "status" || subcommand === "--help") {
const cli = await import("./cli-CCufOVGz.mjs");
const cli = await import("./cli-DHWpcEEa.mjs");
const args = process.argv.slice(3);
process.exitCode = subcommand === "status" ? await cli.runStatus(args) : await cli.runSetup(args);
return;
Expand All @@ -163,9 +219,9 @@ async function runHook() {
return;
}
const hook = typeof payload?.hook_event_name === "string" ? payload.hook_event_name : "";
const response = PASSTHROUGH[hook] ?? {};
const response = { ...PASSTHROUGH[hook] ?? {} };
try {
if (payload) await handle(payload);
if (payload) await handle(payload, response);
} catch (error) {
infoLog(`hook ${hook} failed:`, error);
if (failOnError) process.exitCode = 1;
Expand Down
17 changes: 10 additions & 7 deletions dist/state-BCqq8k6q.mjs → dist/state-DHhYKuAQ.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { i as clipDeep, n as asNumber, r as asString, s as debugLog, u as isRecord } from "./version-ZGirXdLt.mjs";
import { i as clipDeep, n as asNumber, r as asString, s as debugLog, u as isRecord } from "./version-B2EdcJjP.mjs";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import * as fs from "node:fs";

//#region src/config.ts
const DEFAULT_BASE_URL = "https://cloud.langfuse.com";
function parseBoolean(value) {
if (typeof value === "boolean") return value;
if (typeof value !== "string") return void 0;
Expand Down Expand Up @@ -125,14 +124,14 @@ function getConfig(options = {}) {
...envConfig
};
const usesProjectKeys = projectConfig.public_key && projectConfig.secret_key && !envConfig.public_key && !envConfig.secret_key;
const baseUrl = envConfig.base_url ?? (usesProjectKeys ? projectConfig.base_url : globalConfig.base_url) ?? DEFAULT_BASE_URL;
const baseUrl = envConfig.base_url ?? (usesProjectKeys ? projectConfig.base_url : globalConfig.base_url);
const hasKeys = Boolean(merged.public_key && merged.secret_key);
const stateDir = expandHome(merged.state_dir ?? path.join("~", ".cursor", "langfuse"), home);
return {
enabled: (merged.enabled ?? true) && hasKeys,
enabled: (merged.enabled ?? true) && hasKeys && Boolean(baseUrl),
public_key: merged.public_key,
secret_key: merged.secret_key,
base_url: baseUrl.replace(/\/+$/, ""),
base_url: baseUrl?.replace(/\/+$/, "") ?? "",
environment: merged.environment,
release: merged.release,
user_id: merged.user_id,
Expand All @@ -151,7 +150,11 @@ function getConfig(options = {}) {
}
function disabledReason(config, env) {
if (parseBoolean(env.LANGFUSE_CURSOR_ENABLED ?? env.LANGFUSE_TRACING_ENABLED) === false) return "kill switch LANGFUSE_TRACING_ENABLED=false";
if (!config.public_key || !config.secret_key) return `Langfuse config incomplete: missing ${[!config.public_key ? "LANGFUSE_PUBLIC_KEY" : null, !config.secret_key ? "LANGFUSE_SECRET_KEY" : null].filter(Boolean).join(", ")}`;
if (!config.public_key || !config.secret_key || !config.base_url) return `Langfuse config incomplete: missing ${[
!config.public_key ? "LANGFUSE_PUBLIC_KEY" : null,
!config.secret_key ? "LANGFUSE_SECRET_KEY" : null,
!config.base_url ? "LANGFUSE_BASE_URL" : null
].filter(Boolean).join(", ")}`;
return "enabled=false in a langfuse.json config file";
}

Expand Down
2 changes: 1 addition & 1 deletion dist/version-ZGirXdLt.mjs → dist/version-B2EdcJjP.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as path from "node:path";
import * as fs from "node:fs";
import * as path from "node:path";
import { createHash } from "node:crypto";

//#region src/utils.ts
Expand Down
8 changes: 8 additions & 0 deletions rules/langfuse-setup.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
description: Tell the user how to save Langfuse API keys when tracing is not configured
alwaysApply: true
---

When the session context contains the sentence "Langfuse tracing is not configured.", start the first reply with the setup message from that context. Copy it as written, including the line breaks and the credentials file link. Do not add setup steps. Then do the user's task.

When that sentence is absent, do the user's task. Setup instructions stay out of the reply.
Loading
Loading