diff --git a/README.md b/README.md index 5c4652e..6706389 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ That's it! All traces appear automatically in your Langfuse dashboard. This plugin initializes a `LangfuseSpanProcessor` that captures all OpenTelemetry spans emitted by OpenCode when `experimental.openTelemetry` is enabled. +It also adds the OpenCode project ID, working directory, and worktree to every span as filterable Langfuse trace metadata (`project`, `directory`, and `worktree`). + ``` OpenCode (OTEL spans) → LangfuseSpanProcessor → Langfuse Dashboard ``` diff --git a/src/index.test.ts b/src/index.test.ts index c08b77c..30e3a7e 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,7 @@ 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; Object.assign(process.env, overrides); }; @@ -86,6 +90,27 @@ describe("LangfusePlugin", () => { }, }); }); + + it("adds OpenCode context as filterable Langfuse trace metadata", async () => { + setupEnv(); + await LangfusePlugin(mockPluginInput()); + + const span = { setAttribute: mock(() => {}) }; + sdkOptions.spanProcessors[0].onStart(span, {}); + + expect(span.setAttribute).toHaveBeenCalledWith( + "langfuse.trace.metadata.project", + "proj-123" + ); + expect(span.setAttribute).toHaveBeenCalledWith( + "langfuse.trace.metadata.directory", + "/test/dir" + ); + expect(span.setAttribute).toHaveBeenCalledWith( + "langfuse.trace.metadata.worktree", + "/test/worktree" + ); + }); }); describe("config hook", () => { diff --git a/src/index.ts b/src/index.ts index e2cbb45..7db74eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,27 @@ 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"; -export const LangfusePlugin: Plugin = async ({ client }) => { +const createMetadataSpanProcessor = ( + metadata: Record +): SpanProcessor => ({ + onStart: (span) => { + for (const [key, value] of Object.entries(metadata)) { + span.setAttribute(`langfuse.trace.metadata.${key}`, value); + } + }, + onEnd: () => {}, + forceFlush: async () => {}, + shutdown: async () => {}, +}); + +export const LangfusePlugin: Plugin = async ({ + client, + project, + directory, + worktree, +}) => { const publicKey = process.env.LANGFUSE_PUBLIC_KEY; const secretKey = process.env.LANGFUSE_SECRET_KEY; const baseUrl = process.env.LANGFUSE_BASEURL ?? "https://cloud.langfuse.com"; @@ -29,8 +48,14 @@ export const LangfusePlugin: Plugin = async ({ client }) => { environment, }); + const metadataProcessor = createMetadataSpanProcessor({ + project: project.id, + directory, + worktree, + }); + const sdk = new NodeSDK({ - spanProcessors: [processor], + spanProcessors: [metadataProcessor, processor], }); sdk.start();