Skip to content
Open
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
63 changes: 57 additions & 6 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand All @@ -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(() => {});
Expand Down Expand Up @@ -41,6 +43,7 @@ describe("LangfusePlugin", () => {
mockForceFlush.mockClear();
mockStart.mockClear();
mockLog.mockClear();
sdkOptions = undefined;
});

afterEach(() => {
Expand All @@ -50,6 +53,8 @@ describe("LangfusePlugin", () => {
const setupEnv = (overrides: Record<string, string> = {}) => {
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);
};

Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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)",
},
});
});
Expand All @@ -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)",
},
});
});
Expand Down
37 changes: 35 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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)(?:\.|$)/,
];
Comment thread
adumont marked this conversation as resolved.
Resolved

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({
Expand All @@ -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) => {
Expand Down