Auditing Cursor Agents with Langfuse v4

- 04 August 2026 - 13 mins read

A couple of days ago, Langfuse v4 shipped. Quick summary… A full rewrite of the data model, OpenTelemetry-native ingestion, ClickHouse under the hood, and a hard break from the old batch API. So I decided to wire it into Cursor so I could actually see what the agent does when I am not watching the chat panel.

If you have been building or debugging agentic workflows, you know the problem: the harness is a black box. You send a prompt, stuff happens (file reads, shell commands, MCP tool calls, more prompts to the model), and eventually you get a result. Sometimes a good one. Sometimes the agent ran rm -rf in the wrong directory and you only find out when CI explodes.

Cursor exposes lifecycle hooks that fire at every stage of the agent loop. Langfuse v4 accepts traces through OpenTelemetry. Connect the two and you get a full audit trail: every prompt, every tool call, every file edit, grouped by conversation and workspace. No proxy, no gateway, no patching the model API. Just a small script that runs when Cursor tells it to.

This is also a solid way to learn how an agentic harness actually works, because you are literally watching the loop unfold in a trace viewer.

What you are actually tracing

A good way to understand an agentic coding harness is to think of it as a loop:

  1. You submit a prompt.
  2. The model decides what to do next (respond, read a file, run a command, call an MCP tool… whatever).
  3. The harness executes that action and feeds the result back into the model.
  4. Repeat until the task completes, errors out, you abort… or the API is too busy and you need to go out for a walk.

Cursor runs this loop in the background. The chat UI shows you summaries. The hooks show you everything.

Hook What it captures
beforeSubmitPrompt Your prompt (and attachments) before it hits the model
afterAgentResponse The agent’s text response
afterAgentThought Reasoning blocks when the model “thinks”
beforeReadFile / afterFileEdit File access and edits
beforeShellExecution / afterShellExecution Shell commands and their output
beforeMCPExecution / afterMCPExecution MCP tool invocations and results
stop Session end (completed, aborted, error)

Each hook spawns a short-lived process. Cursor passes a JSON payload on stdin. Your script reads it, records an observation, flushes to Langfuse aaaand exits. The agent loop does not wait on your observability stack; hooks are designed to fail gracefully so a broken tracer never blocks a file edit.

That last point matters. If your handler crashes, return { "continue": true, "permission": "allow" } and log the error. Observability should never become a footgun.

Langfuse v4: forget what you read about v3

If you googled “Langfuse self-host” a month ago, you got a docker-compose with Postgres and a langfuse npm package that sends trace-create events to /api/public/ingestion. That path is dead on v4.

Langfuse v4 runs in events_only write mode by default. The legacy batch ingestion API rejects trace-create, span-create, and generation-create events. The v3 JavaScript SDK (langfuse on npm) still sends those. Your traces will silently disappear and you will stare at an empty dashboard wondering why.

The v4 stack is:

  • Ingestion: OpenTelemetry spans via @langfuse/otel (LangfuseSpanProcessor)
  • Tracing API: @langfuse/tracing (startObservation, propagateAttributes, createTraceId)
  • Storage: Postgres for metadata, ClickHouse for observations, Redis for queues, S3-compatible blob store for payloads

Self-hosting means six containers minimum (web, worker, Postgres, ClickHouse, Redis, MinIO), but the query performance on large trace volumes is worth it if you actually use the data.

For this guide, grab the official docker-compose, replace the CHANGEME secrets, and run:

docker compose up -d

Give ClickHouse a minute on first boot. Open http://localhost:3000, create your admin account, create a project, and copy the API keys from Settings → API Keys. You need a public key (pk-lf-...) and a secret key (sk-lf-...).

Wiring Cursor hooks to Langfuse

Hooks live in hooks.json. You can put them globally (~/.cursor/hooks.json) or per project (<repo>/.cursor/hooks.json). Global hooks run from ~/.cursor/; project hooks run from the repo root. I use global hooks because I want tracing everywhere, not just one codebase.

Create the directory structure:

~/.cursor/
  hooks.json
  hooks/
    hook-handler.js
    package.json
    .env
    lib/
      langfuse-client.js
      handlers.js
      utils.js

hooks.json

This registers one handler for every lifecycle event:

{
  "version": 1,
  "hooks": {
    "beforeSubmitPrompt": [{ "command": "node hooks/hook-handler.js" }],
    "afterAgentResponse": [{ "command": "node hooks/hook-handler.js" }],
    "afterAgentThought": [{ "command": "node hooks/hook-handler.js" }],
    "beforeShellExecution": [{ "command": "node hooks/hook-handler.js" }],
    "afterShellExecution": [{ "command": "node hooks/hook-handler.js" }],
    "beforeMCPExecution": [{ "command": "node hooks/hook-handler.js" }],
    "afterMCPExecution": [{ "command": "node hooks/hook-handler.js" }],
    "beforeReadFile": [{ "command": "node hooks/hook-handler.js" }],
    "afterFileEdit": [{ "command": "node hooks/hook-handler.js" }],
    "stop": [{ "command": "node hooks/hook-handler.js" }],
    "beforeTabFileRead": [{ "command": "node hooks/hook-handler.js" }],
    "afterTabFileEdit": [{ "command": "node hooks/hook-handler.js" }]
  }
}

For project-level hooks, change paths to .cursor/hooks/hook-handler.js.

Credentials

LANGFUSE_PUBLIC_KEY=pk-lf-your-key
LANGFUSE_SECRET_KEY=sk-lf-your-key
LANGFUSE_BASE_URL=http://localhost:3000

package.json

{
  "name": "cursor-langfuse-hooks",
  "type": "module",
  "dependencies": {
    "@langfuse/otel": "^4.0.0",
    "@langfuse/tracing": "^4.0.0",
    "@opentelemetry/api": "^1.9.0",
    "@opentelemetry/sdk-node": "^0.205.0",
    "dotenv": "^16.4.5"
  }
}

Run npm install inside ~/.cursor/hooks/.

The OTEL bridge (langfuse-client.js)

This is the piece that v3 tutorials skip. Initialize OpenTelemetry with Langfuse’s span processor:

import { NodeSDK } from "@opentelemetry/sdk-node";
import { LangfuseSpanProcessor } from "@langfuse/otel";
import { createTraceId, startObservation, propagateAttributes } from "@langfuse/tracing";

export const langfuseSpanProcessor = new LangfuseSpanProcessor({
  publicKey: process.env.LANGFUSE_PUBLIC_KEY,
  secretKey: process.env.LANGFUSE_SECRET_KEY,
  baseUrl: process.env.LANGFUSE_BASE_URL,
  exportMode: "immediate",
  shouldExportSpan: () => true,
});

const sdk = new NodeSDK({ spanProcessors: [langfuseSpanProcessor] });
sdk.start();

export async function createHookContext(input) {
  const traceId = await createTraceId(input.conversation_id);
  return {
    traceId,
    parentSpanContext: {
      traceId,
      spanId: traceId.slice(16, 32),
      traceFlags: 1,
    },
    sessionId: input.workspace_roots?.[0]?.split("/").pop() ?? "default",
    input,
  };
}

export function recordObservation(ctx, name, { input, output, asType = "span", model } = {}) {
  propagateAttributes({ sessionId: ctx.sessionId }, () => {
    const obs = startObservation(name, { input, output, model }, {
      asType,
      parentSpanContext: ctx.parentSpanContext,
    });
    obs.end();
  });
}

export async function flushLangfuse() {
  await langfuseSpanProcessor.forceFlush();
}

Three details that will bite you if you miss them:

  1. exportMode: "immediate": Hooks are short-lived processes. Without immediate export (or a forceFlush() before exit), spans never leave the process.
  2. shouldExportSpan: () => true: The default filter only exports LLM-related spans. Cursor hooks produce shell commands, file reads, and MCP calls. You want all of them.
  3. createTraceId(conversation_id): Groups every hook invocation from the same chat into one trace, even though each hook runs in a separate process.

The entry point (hook-handler.js)

import { readStdin } from "./lib/utils.js";
import { createHookContext, flushLangfuse } from "./lib/langfuse-client.js";
import { routeHookHandler } from "./lib/handlers.js";

async function main() {
  const input = await readStdin();
  const ctx = await createHookContext(input);
  const response = routeHookHandler(input.hook_event_name, ctx, input);

  if (response) console.log(JSON.stringify(response));
  await flushLangfuse();
}

main().catch((err) => {
  console.error(err.message);
  console.log(JSON.stringify({ continue: true, permission: "allow" }));
  process.exit(1);
});

readStdin is a ten-line JSON parser. routeHookHandler maps hook names to small functions that call recordObservation with the right asType (generation for prompts and responses, span for tools and file ops).

Restart Cursor after creating the hooks. Project hooks only run in trusted workspaces; if nothing appears, check that your workspace is trusted and that Cursor picked up the config (a full restart helps).

What you see in Langfuse

Open your project dashboard. After a single agent conversation you should see:

  • A trace per conversation, named from the first prompt
  • A session keyed to your workspace folder name
  • Generations for user prompts and agent responses (with model name when Cursor provides it)
  • Spans for shell commands, file reads, file edits, MCP calls
  • Events on session stop (completed / aborted / error)

Filter by session to see everything that happened in one repo. Filter by tags (if you add them in propagateAttributes) to find sessions that used shell commands or MCP tools. Click into a trace and you get the full timeline: the exact command the agent ran, the file it edited, the MCP tool it called, and what came back.

This is where the “learn how the harness works” part clicks. You watch the agent read three files before editing the wrong one. You see it grep for a symbol, misread the output, and run a destructive command. You see how many loop iterations a “simple” task actually takes. That loop count alone has changed how I write prompts.

Why this beats staring at the chat panel

Cursor’s chat UI is good, but just a summary. Langfuse is so nice because it shows the actual transcript. The difference matters when you are:

  • Tuning prompts: See which instructions the agent actually follows vs. ignores
  • Debugging failures: Find the exact shell command or tool call that went wrong
  • Estimating cost: Count model turns and loop iterations per task
  • Auditing agent behavior: Prove what the agent touched in a repo during a session
  • Learning the harness: Watch the read → think → act → read again cycle in real traces

You do not need to build a custom agent framework to get this. Cursor already runs the loop. Langfuse v4 already ingests OTEL spans. The hooks are the glue, and the glue is about fifty lines of JavaScript plus a hooks.json file.

Give it a weekend. Spin up Langfuse, wire the hooks, run one agent task, and open the trace. You will never look at agent debugging the same way again.


StoryScope score: 52/100

Share: Link copied to clipboard

Tags:

Previous: Adding a Qwen-powered Memory-Augmented Agent to the NaLog Platform

Where: Home > Technical > Auditing Cursor Agents with Langfuse v4