diff --git a/README.md b/README.md index 5c4652e..51ff696 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,9 @@ OpenCode (OTEL spans) → LangfuseSpanProcessor → Langfuse Dashboard | `LANGFUSE_PUBLIC_KEY` | Yes | - | Langfuse public key | | `LANGFUSE_SECRET_KEY` | Yes | - | Langfuse secret key | | `LANGFUSE_BASEURL` | No | `https://cloud.langfuse.com` | Self-hosted instance | +| `LANGFUSE_CAPTURE_CONTENT` | No | `true` | Set to `false` to redact prompt and response content before export | + +Set `LANGFUSE_CAPTURE_CONTENT=false` to keep trace structure and non-content attributes while replacing recognized input/output content with `[REDACTED]` before it is sent to Langfuse. Content capture is enabled by default; only the exact value `false` (case-insensitive) disables it. --- diff --git a/src/index.test.ts b/src/index.test.ts index c08b77c..9c2fc13 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -3,6 +3,7 @@ import { LangfusePlugin } from "./index"; const mockForceFlush = mock(() => Promise.resolve()); const mockStart = mock(() => {}); +let sdkOptions: any; mock.module("@langfuse/otel", () => ({ LangfuseSpanProcessor: mock(() => ({ @@ -11,9 +12,10 @@ mock.module("@langfuse/otel", () => ({ })); mock.module("@opentelemetry/sdk-node", () => ({ - NodeSDK: mock(() => ({ - start: mockStart, - })), + NodeSDK: mock((options: any) => { + sdkOptions = options; + return { start: mockStart }; + }), })); const mockLog = mock(() => {}); @@ -41,6 +43,7 @@ describe("LangfusePlugin", () => { mockForceFlush.mockClear(); mockStart.mockClear(); mockLog.mockClear(); + sdkOptions = undefined; }); afterEach(() => { @@ -50,6 +53,8 @@ describe("LangfusePlugin", () => { const setupEnv = (overrides: Record = {}) => { process.env.LANGFUSE_PUBLIC_KEY = "pk-test"; process.env.LANGFUSE_SECRET_KEY = "sk-test"; + delete process.env.LANGFUSE_BASEURL; + delete process.env.LANGFUSE_CAPTURE_CONTENT; Object.assign(process.env, overrides); }; @@ -82,10 +87,54 @@ describe("LangfusePlugin", () => { body: { service: "langfuse-otel", level: "info", - message: "OTEL tracing initialized → https://cloud.langfuse.com", + message: + "OTEL tracing initialized → https://cloud.langfuse.com (content capture enabled)", }, }); }); + + it("redacts content attributes when captureContent is disabled", async () => { + setupEnv({ LANGFUSE_CAPTURE_CONTENT: "false" }); + await LangfusePlugin(mockPluginInput()); + + expect(sdkOptions.spanProcessors).toHaveLength(2); + const span = { + attributes: { + "langfuse.observation.input": "private prompt", + "langfuse.observation.output": "private response", + "gen_ai.input.messages": '[{"content":"private prompt"}]', + "gen_ai.output.messages": '[{"content":"private response"}]', + "gen_ai.system_instructions": "private system prompt", + "input.value": "private input", + "output.value": "private output", + "ai.toolCall.args": '{"query":"private tool input"}', + "ai.toolCall.result": '{"data":"private tool output"}', + "gen_ai.request.model": "model-name", + "opencode.client": "opencode", + }, + }; + + sdkOptions.spanProcessors[0].onEnd(span); + + expect(span.attributes["langfuse.observation.input"]).toBe("[REDACTED]"); + expect(span.attributes["langfuse.observation.output"]).toBe("[REDACTED]"); + expect(span.attributes["gen_ai.input.messages"]).toBe("[REDACTED]"); + expect(span.attributes["gen_ai.output.messages"]).toBe("[REDACTED]"); + expect(span.attributes["gen_ai.system_instructions"]).toBe("[REDACTED]"); + expect(span.attributes["input.value"]).toBe("[REDACTED]"); + expect(span.attributes["output.value"]).toBe("[REDACTED]"); + expect(span.attributes["ai.toolCall.args"]).toBe("[REDACTED]"); + expect(span.attributes["ai.toolCall.result"]).toBe("[REDACTED]"); + expect(span.attributes["gen_ai.request.model"]).toBe("model-name"); + expect(span.attributes["opencode.client"]).toBe("opencode"); + }); + + it("keeps content capture enabled unless explicitly disabled", async () => { + setupEnv({ LANGFUSE_CAPTURE_CONTENT: "0" }); + await LangfusePlugin(mockPluginInput()); + + expect(sdkOptions.spanProcessors).toHaveLength(1); + }); }); describe("config hook", () => { @@ -178,7 +227,8 @@ describe("LangfusePlugin", () => { body: { service: "langfuse-otel", level: "info", - message: "OTEL tracing initialized → https://cloud.langfuse.com", + message: + "OTEL tracing initialized → https://cloud.langfuse.com (content capture enabled)", }, }); }); @@ -192,7 +242,8 @@ describe("LangfusePlugin", () => { body: { service: "langfuse-otel", level: "info", - message: "OTEL tracing initialized → https://custom.langfuse.com", + message: + "OTEL tracing initialized → https://custom.langfuse.com (content capture enabled)", }, }); }); diff --git a/src/index.ts b/src/index.ts index e2cbb45..152490b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,40 @@ import { LangfuseSpanProcessor } from "@langfuse/otel"; import type { Plugin } from "@opencode-ai/plugin"; +import type { SpanProcessor } from "@opentelemetry/sdk-trace-base"; import { NodeSDK } from "@opentelemetry/sdk-node"; +const CONTENT_ATTRIBUTE_PATTERNS = [ + /^langfuse\.(?:observation|trace)\.(?:input|output)$/, + /^gen_ai\.(?:input\.messages|output\.messages|prompt|completion)(?:\.|$)/, + /^gen_ai\.tool\.call\.(?:arguments|result)$/, + /^gen_ai\.(?:system_instructions|tool\.definitions)(?:\.|$)/, + /^(?:input|output)\.value$/, + /^ai\.toolCall\.(?:args|result)(?:\.|$)/, + /^ai\.prompt(?:\.|$)/, + /^ai\.response\.(?:text|object|toolCalls|toolResults)(?:\.|$)/, + /^llm\.(?:input_messages|output_messages|prompts|completions)(?:\.|$)/, +]; + +const createContentRedactionProcessor = (): SpanProcessor => ({ + onStart: () => {}, + onEnd: (span) => { + for (const key of Object.keys(span.attributes)) { + if (CONTENT_ATTRIBUTE_PATTERNS.some((pattern) => pattern.test(key))) { + span.attributes[key] = "[REDACTED]"; + } + } + }, + forceFlush: async () => {}, + shutdown: async () => {}, +}); + export const LangfusePlugin: Plugin = async ({ client }) => { const publicKey = process.env.LANGFUSE_PUBLIC_KEY; const secretKey = process.env.LANGFUSE_SECRET_KEY; const baseUrl = process.env.LANGFUSE_BASEURL ?? "https://cloud.langfuse.com"; const environment = process.env.LANGFUSE_ENVIRONMENT ?? "development"; + const captureContent = + process.env.LANGFUSE_CAPTURE_CONTENT?.trim().toLowerCase() !== "false"; const log = (level: "info" | "warn" | "error", message: string) => { client.app.log({ @@ -30,11 +58,16 @@ export const LangfusePlugin: Plugin = async ({ client }) => { }); const sdk = new NodeSDK({ - spanProcessors: [processor], + spanProcessors: captureContent + ? [processor] + : [createContentRedactionProcessor(), processor], }); sdk.start(); - log("info", `OTEL tracing initialized → ${baseUrl}`); + log( + "info", + `OTEL tracing initialized → ${baseUrl} (content capture ${captureContent ? "enabled" : "disabled"})` + ); return { config: async (config) => {