From 4ff1cfd4c2b02534e986405e9936c068b5482304 Mon Sep 17 00:00:00 2001 From: tfontana <52676351+tfon23@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:45:30 -0400 Subject: [PATCH] Add configurable trace tags (#57) Allow setting Langfuse trace tags from the opencode-langfuse.json config file (tags array) or as a plugin option in the OpenCode config, which takes precedence. Tags are applied to every exported span via the langfuse.trace.tags attribute. --- README.md | 23 +- src/langfuse.ts | 14 ++ src/runtime.ts | 23 +- src/v1.ts | 353 ++++++++++++++++--------------- src/v2.ts | 11 +- test/integration/runtime.test.ts | 54 ++++- test/integration/v1.test.ts | 40 +++- test/integration/v2.test.ts | 124 ++++++++++- 8 files changed, 458 insertions(+), 184 deletions(-) diff --git a/README.md b/README.md index dfecdca..60ddea6 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,33 @@ Create `~/.config/opencode/opencode-langfuse.json` with your Langfuse credential "secretKey": "sk-lf-...", "baseUrl": "https://cloud.langfuse.com", "environment": "development", - "userId": "your-user-id" + "userId": "your-user-id", + "tags": ["app:my-app"] } ``` Only `publicKey` and `secretKey` are required. If `baseUrl` is not set, the plugin uses `https://cloud.langfuse.com`. If `environment` is not set, it uses `development`. +`tags` sets Langfuse trace tags on every trace. It accepts an array of non-empty strings and can also be provided as a plugin option in the OpenCode config, which takes precedence over the config file: + +```json +{ + "plugins": [ + ["@langfuse/opencode-observability-plugin", { "tags": ["app:my-app"] }] + ] +} +``` + +For OpenCode 1, use the same option under `plugin`: + +```json +{ + "plugin": [ + ["@langfuse/opencode-observability-plugin", { "tags": ["app:my-app"] }] + ] +} +``` + You can also set credentials with environment variables: ```bash diff --git a/src/langfuse.ts b/src/langfuse.ts index d3cf46c..02436ca 100644 --- a/src/langfuse.ts +++ b/src/langfuse.ts @@ -1990,6 +1990,16 @@ const makeUserIdSpanProcessor = (userId: string) => forceFlush: () => Promise.resolve(), }) satisfies SpanProcessor; +const makeTagsSpanProcessor = (tags: string[]) => + ({ + onStart: (span: Span) => { + span.setAttribute("langfuse.trace.tags", tags); + }, + onEnd: () => undefined, + shutdown: () => Promise.resolve(), + forceFlush: () => Promise.resolve(), + }) satisfies SpanProcessor; + const makePluginVersionSpanProcessor = () => ({ onStart: (span: Span) => { @@ -2038,6 +2048,7 @@ export const createLangfuseClient = (input: { environment: string; userId?: string; serviceName?: string; + tags?: string[]; opencodeVersion?: string; }) => Effect.sync(() => { @@ -2067,6 +2078,9 @@ export const createLangfuseClient = (input: { ...(input.userId != null ? [makeUserIdSpanProcessor(input.userId)] : []), + ...(input.tags != null && input.tags.length > 0 + ? [makeTagsSpanProcessor(input.tags)] + : []), processor, makeAppRootSpanProcessor(tracerName), ], diff --git a/src/runtime.ts b/src/runtime.ts index 3ce089e..2ea8bd2 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { Data, Effect, + Option, Schema, SynchronizedRef, type SynchronizedRef as SynchronizedRefType, @@ -12,6 +13,8 @@ import { import { createLangfuseClient, type LangfuseClient } from "./langfuse.js"; +const TagsArraySchema = Schema.Array(Schema.NonEmptyString); + const LangfuseCredentialsSchema = Schema.Struct({ publicKey: Schema.NonEmptyString, secretKey: Schema.NonEmptyString, @@ -19,10 +22,22 @@ const LangfuseCredentialsSchema = Schema.Struct({ environment: Schema.optional(Schema.NonEmptyString), userId: Schema.optional(Schema.NonEmptyString), serviceName: Schema.optional(Schema.NonEmptyString), + tags: Schema.optional(TagsArraySchema), }); type LangfuseCredentials = typeof LangfuseCredentialsSchema.Type; +const PluginOptionsSchema = Schema.Struct({ + tags: Schema.optional(TagsArraySchema), +}); + +// Decodes the optional `tags` plugin option. Unknown or malformed values are +// dropped so a bad option never disables tracing for the rest of the config. +export const decodePluginTags = (options: unknown) => { + const decoded = Schema.decodeUnknownOption(PluginOptionsSchema)(options); + return Option.isSome(decoded) ? decoded.value.tags?.slice() : undefined; +}; + class MissingLangfuseCredentials extends Data.TaggedError( "MissingLangfuseCredentials", )<{ readonly message: string }> {} @@ -111,7 +126,10 @@ declare global { | undefined; } -export const createLangfuseRuntime = (input: { opencodeVersion?: string }) => +export const createLangfuseRuntime = (input: { + opencodeVersion?: string; + tags?: string[]; +}) => Effect.gen(function* () { const credentials = yield* loadLangfuseCredentials; const clientInput = { @@ -128,6 +146,9 @@ export const createLangfuseRuntime = (input: { opencodeVersion?: string }) => "development", userId: credentials.userId ?? process.env.LANGFUSE_USER_ID, serviceName: credentials.serviceName ?? process.env.LANGFUSE_SERVICE_NAME, + // Plugin options win over the config file, mirroring how environment + // variables override it for the other settings. + tags: input.tags ?? credentials.tags?.slice(), opencodeVersion: input.opencodeVersion, } satisfies Parameters[0]; const cacheKey = JSON.stringify(clientInput); diff --git a/src/v1.ts b/src/v1.ts index 65d865f..2739784 100644 --- a/src/v1.ts +++ b/src/v1.ts @@ -7,7 +7,7 @@ import { type ToolDefinition, } from "./langfuse.js"; import { OpencodeClientService } from "./opencode.js"; -import { createLangfuseRuntime } from "./runtime.js"; +import { createLangfuseRuntime, decodePluginTags } from "./runtime.js"; import { McpContentSchema, McpToolResultSchema, @@ -336,202 +336,213 @@ const normalizeToolResult = (tool: string, output: unknown) => { return { title: tool, output: "", isError: false, unexpected: true }; }; -const main = Effect.gen(function* () { - const opencode = yield* OpencodeClientService; - const disableTracing = (error: { readonly message: string }) => - log("warn", `[Tracing disabled] ${error.message}`).pipe( - Effect.as(undefined), - ); +const main = (tags: string[] | undefined) => + Effect.gen(function* () { + const opencode = yield* OpencodeClientService; + const disableTracing = (error: { readonly message: string }) => + log("warn", `[Tracing disabled] ${error.message}`).pipe( + Effect.as(undefined), + ); - const langfuse = yield* createLangfuseRuntime({}).pipe( - Effect.catchTag("MissingLangfuseCredentials", disableTracing), - ); + const langfuse = yield* createLangfuseRuntime({ tags }).pipe( + Effect.catchTag("MissingLangfuseCredentials", disableTracing), + ); - if (!langfuse) { - return {}; - } + if (!langfuse) { + return {}; + } - yield* log("info", `OTEL tracing initialized → ${langfuse.baseUrl}`); + yield* log("info", `OTEL tracing initialized → ${langfuse.baseUrl}`); - const hooksLayer = Layer.merge( - Layer.succeed(OpencodeClientService, opencode), - Layer.succeed(LangfuseClientService, langfuse), - ); + const hooksLayer = Layer.merge( + Layer.succeed(OpencodeClientService, opencode), + Layer.succeed(LangfuseClientService, langfuse), + ); - const finalizeTracing = Effect.sync(() => { - langfuse.endActiveToolObservations(); - langfuse.endActiveGenerationSteps(); - langfuse.endActiveTurnObservations(); - langfuse.clearTraceState(); - }); - const toolDefinitions = new Map>(); - - const runHook = ( - hookName: string, - effect: Effect.Effect< - unknown, - unknown, - OpencodeClientService | LangfuseClientService - >, - ) => - Effect.runPromise( - effect.pipe( - Effect.catchDefect((defect) => - log( - "error", - `Langfuse hook "${hookName}" failed: ${formatHookError(defect)}`, - ).pipe(Effect.catch(() => Effect.void)), - ), - Effect.catch((error) => - log( - "error", - `Langfuse hook "${hookName}" failed: ${formatHookError(error)}`, - ).pipe(Effect.catch(() => Effect.void)), + const finalizeTracing = Effect.sync(() => { + langfuse.endActiveToolObservations(); + langfuse.endActiveGenerationSteps(); + langfuse.endActiveTurnObservations(); + langfuse.clearTraceState(); + }); + const toolDefinitions = new Map>(); + + const runHook = ( + hookName: string, + effect: Effect.Effect< + unknown, + unknown, + OpencodeClientService | LangfuseClientService + >, + ) => + Effect.runPromise( + effect.pipe( + Effect.catchDefect((defect) => + log( + "error", + `Langfuse hook "${hookName}" failed: ${formatHookError(defect)}`, + ).pipe(Effect.catch(() => Effect.void)), + ), + Effect.catch((error) => + log( + "error", + `Langfuse hook "${hookName}" failed: ${formatHookError(error)}`, + ).pipe(Effect.catch(() => Effect.void)), + ), + Effect.asVoid, + Effect.provide(hooksLayer), ), - Effect.asVoid, - Effect.provide(hooksLayer), - ), - ); + ); - const hooks: Hooks = { - dispose: () => - runHook( - "dispose", - finalizeTracing.pipe(Effect.andThen(langfuse.forceFlush)), - ), + const hooks: Hooks = { + dispose: () => + runHook( + "dispose", + finalizeTracing.pipe(Effect.andThen(langfuse.forceFlush)), + ), - event: ({ event }) => runHook("event", eventHook(event)), - - "chat.message": (input, output) => - runHook( - "chat.message", - Effect.gen(function* () { - let tools: ToolDefinition[] | undefined; - - if (input.model) { - const enabledTools = output.message.tools; - const cacheKey = JSON.stringify([ - input.model.providerID, - input.model.modelID, - enabledTools, - ]); - let pendingTools = toolDefinitions.get(cacheKey); - - if (!pendingTools) { - pendingTools = opencode.tool - .list({ - query: { - provider: input.model.providerID, - model: input.model.modelID, - }, - }) - .then(({ data }) => - (data ?? []) - .filter((tool) => enabledTools?.[tool.id] !== false) - .map((tool) => ({ - name: tool.id, - description: tool.description, - ...(typeof tool.parameters === "object" && - tool.parameters !== null && - !Array.isArray(tool.parameters) - ? { - parameters: tool.parameters, - } - : {}), - })), - ) - .catch(() => { - toolDefinitions.delete(cacheKey); - return []; - }); - toolDefinitions.set(cacheKey, pendingTools); + event: ({ event }) => runHook("event", eventHook(event)), + + "chat.message": (input, output) => + runHook( + "chat.message", + Effect.gen(function* () { + let tools: ToolDefinition[] | undefined; + + if (input.model) { + const enabledTools = output.message.tools; + const cacheKey = JSON.stringify([ + input.model.providerID, + input.model.modelID, + enabledTools, + ]); + let pendingTools = toolDefinitions.get(cacheKey); + + if (!pendingTools) { + pendingTools = opencode.tool + .list({ + query: { + provider: input.model.providerID, + model: input.model.modelID, + }, + }) + .then(({ data }) => + (data ?? []) + .filter((tool) => enabledTools?.[tool.id] !== false) + .map((tool) => ({ + name: tool.id, + description: tool.description, + ...(typeof tool.parameters === "object" && + tool.parameters !== null && + !Array.isArray(tool.parameters) + ? { + parameters: tool.parameters, + } + : {}), + })), + ) + .catch(() => { + toolDefinitions.delete(cacheKey); + return []; + }); + toolDefinitions.set(cacheKey, pendingTools); + } + + tools = yield* Effect.promise(() => pendingTools); } - tools = yield* Effect.promise(() => pendingTools); - } - - yield* Effect.sync(() => { - langfuse.traceUserMessage({ - sessionID: input.sessionID, - messageID: input.messageID, - agent: input.agent, - model: input.model, - parts: output.parts, - tools, - }); - }); - }), - ), - - "tool.execute.before": (input, output) => - runHook( - "tool.execute.before", - Effect.try({ - try: () => { - langfuse.traceToolStart({ - sessionID: input.sessionID, - callID: input.callID, - tool: input.tool, - args: output.args, - }); - }, - catch: (error) => error, - }), - ), - - "tool.execute.after": (input, output) => - runHook( - "tool.execute.after", - Effect.gen(function* () { - const normalized = normalizeToolResult(input.tool, output); - - if (normalized.unexpected) { - yield* log( - "warn", - `Tool "${input.tool}" returned an unrecognized result shape; recording empty output`, - ); - } - - if (normalized.isError) { yield* Effect.sync(() => { - langfuse.traceToolError({ + langfuse.traceUserMessage({ sessionID: input.sessionID, - callID: input.callID, - tool: input.tool, - args: input.args, - error: - normalized.output || - `MCP tool "${input.tool}" returned an error`, - completed: Date.now(), + messageID: input.messageID, + agent: input.agent, + model: input.model, + parts: output.parts, + tools, }); }); - return; - } + }), + ), - yield* Effect.try({ + "tool.execute.before": (input, output) => + runHook( + "tool.execute.before", + Effect.try({ try: () => { - langfuse.traceToolEnd({ + langfuse.traceToolStart({ sessionID: input.sessionID, callID: input.callID, tool: input.tool, - args: input.args, - title: normalized.title, - output: normalized.output, + args: output.args, }); }, catch: (error) => error, - }); - }), - ), - }; + }), + ), + + "tool.execute.after": (input, output) => + runHook( + "tool.execute.after", + Effect.gen(function* () { + const normalized = normalizeToolResult(input.tool, output); + + if (normalized.unexpected) { + yield* log( + "warn", + `Tool "${input.tool}" returned an unrecognized result shape; recording empty output`, + ); + } + + if (normalized.isError) { + yield* Effect.sync(() => { + langfuse.traceToolError({ + sessionID: input.sessionID, + callID: input.callID, + tool: input.tool, + args: input.args, + error: + normalized.output || + `MCP tool "${input.tool}" returned an error`, + completed: Date.now(), + }); + }); + return; + } - return hooks; -}); + yield* Effect.try({ + try: () => { + langfuse.traceToolEnd({ + sessionID: input.sessionID, + callID: input.callID, + tool: input.tool, + args: input.args, + title: normalized.title, + output: normalized.output, + }); + }, + catch: (error) => error, + }); + }), + ), + }; + + return hooks; + }); -const LangfusePlugin: Plugin = async ({ client }) => { +const LangfusePlugin: Plugin = async ({ client }, options) => { const clientLayer = Layer.succeed(OpencodeClientService, client); + const tags = decodePluginTags(options); + + if (options?.tags !== undefined && tags === undefined) { + await Effect.runPromise( + log( + "warn", + `Ignoring invalid plugin option "tags"; expected an array of non-empty strings`, + ).pipe(Effect.provide(clientLayer)), + ); + } - return Effect.runPromise(main.pipe(Effect.provide(clientLayer))); + return Effect.runPromise(main(tags).pipe(Effect.provide(clientLayer))); }; export default LangfusePlugin; diff --git a/src/v2.ts b/src/v2.ts index 11734bb..17ac0de 100644 --- a/src/v2.ts +++ b/src/v2.ts @@ -3,7 +3,7 @@ import type { SessionHooks } from "@opencode/plugin/promise/session"; import { Effect, Schema } from "effect"; import type { ToolDefinition } from "./langfuse.js"; -import { createLangfuseRuntime } from "./runtime.js"; +import { createLangfuseRuntime, decodePluginTags } from "./runtime.js"; import { PLUGIN_VERSION } from "./version.js"; const SpanContextSchema = Schema.Struct({ @@ -57,8 +57,15 @@ const LangfusePlugin = { console.warn(`[Langfuse tracing disabled] ${error.message}`); }).pipe(Effect.as(undefined)); + const tags = decodePluginTags(ctx.options); + if (ctx.options.tags !== undefined && tags === undefined) { + console.warn( + `[Langfuse] Ignoring invalid plugin option "tags"; expected an array of non-empty strings`, + ); + } + const langfuse = await Effect.runPromise( - createLangfuseRuntime({ opencodeVersion: ctx.app.version }).pipe( + createLangfuseRuntime({ opencodeVersion: ctx.app.version, tags }).pipe( Effect.catchTag("MissingLangfuseCredentials", disableTracing), ), ); diff --git a/test/integration/runtime.test.ts b/test/integration/runtime.test.ts index 17fd316..b5be1e8 100644 --- a/test/integration/runtime.test.ts +++ b/test/integration/runtime.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { Effect } from "effect"; @@ -108,6 +108,58 @@ describe("Langfuse runtime", () => { }); }); + test("loads config file tags and lets plugin options override them", async () => { + const clientInputs: { tags?: string[] }[] = []; + const client = { id: "tags-client" }; + + vi.doMock("../../src/langfuse.js", () => ({ + createLangfuseClient: (input: { tags?: string[] }) => { + clientInputs.push(input); + return Effect.succeed(client); + }, + })); + + temporaryHome = await mkdtemp(join(process.cwd(), ".test-runtime-")); + process.env.HOME = temporaryHome; + delete process.env.LANGFUSE_PUBLIC_KEY; + delete process.env.LANGFUSE_SECRET_KEY; + const configDirectory = join(temporaryHome, ".config", "opencode"); + await mkdir(configDirectory, { recursive: true }); + await writeFile( + join(configDirectory, "opencode-langfuse.json"), + JSON.stringify({ + publicKey: "pk-config", + secretKey: "sk-config", + tags: ["from-config"], + }), + ); + + const { createLangfuseRuntime } = await import("../../src/runtime.js"); + + await Effect.runPromise(createLangfuseRuntime({})); + globalThis.langfuseOpencodeRuntimeState = undefined; + await Effect.runPromise(createLangfuseRuntime({ tags: ["from-options"] })); + + expect(clientInputs.map((input) => input.tags)).toEqual([ + ["from-config"], + ["from-options"], + ]); + }); + + test("decodes the tags plugin option", async () => { + const { decodePluginTags } = await import("../../src/runtime.js"); + + expect(decodePluginTags({ tags: ["a", "b"] })).toEqual(["a", "b"]); + expect(decodePluginTags({})).toBeUndefined(); + expect(decodePluginTags(undefined)).toBeUndefined(); + expect(decodePluginTags(null)).toBeUndefined(); + expect(decodePluginTags({ tags: "not-an-array" })).toBeUndefined(); + expect(decodePluginTags({ tags: ["", "valid"] })).toBeUndefined(); + expect(decodePluginTags({ tags: null })).toBeUndefined(); + expect(decodePluginTags({ tags: [] })).toEqual([]); + expect(decodePluginTags({ tags: ["a"], unknownKey: 1 })).toEqual(["a"]); + }); + test("does not accept empty environment credentials", async () => { vi.stubGlobal("__PLUGIN_VERSION__", "test"); const { createLangfuseRuntime } = await import("../../src/runtime.js"); diff --git a/test/integration/v1.test.ts b/test/integration/v1.test.ts index 9a3da1f..181f557 100644 --- a/test/integration/v1.test.ts +++ b/test/integration/v1.test.ts @@ -27,6 +27,13 @@ const OtlpValueSchema = Schema.Struct({ stringValue: Schema.optional(Schema.String), boolValue: Schema.optional(Schema.Boolean), intValue: Schema.optional(Schema.Number), + arrayValue: Schema.optional( + Schema.Struct({ + values: Schema.Array( + Schema.Struct({ stringValue: Schema.optional(Schema.String) }), + ), + }), + ), }); const OtlpAttributeSchema = Schema.Struct({ key: Schema.String, @@ -459,7 +466,10 @@ const completeGeneration = async (input: { }); }; -const createHooks = async (baseUrl: string) => { +const createHooks = async ( + baseUrl: string, + options?: Parameters[1], +) => { process.env.LANGFUSE_BASE_URL = baseUrl; const client = Schema.decodeUnknownSync(PluginClientSchema)({ app: { @@ -505,7 +515,10 @@ const createHooks = async (baseUrl: string) => { }, }); - return plugin(Schema.decodeUnknownSync(PluginInputSchema)({ client })); + return plugin( + Schema.decodeUnknownSync(PluginInputSchema)({ client }), + options, + ); }; const disposeHooks = async () => { @@ -3593,6 +3606,29 @@ describe("built plugin", { concurrent: false }, () => { } }); + test("applies plugin option tags to exported spans", async () => { + await disposeHooks(); + hooksDisposed = false; + hooks = await createHooks(collectorBaseUrl, { tags: ["app:v1-tags"] }); + + const sessionID = "v1-tags-session"; + await sendUserMessage({ + sessionID, + messageID: "v1-tags-user", + text: "Tag this trace", + started: startedAt, + }); + + const { spans } = await flushSession(sessionID); + const tagged = spans.filter((span) => { + const values = span.attributes.find( + (attribute) => attribute.key === "langfuse.trace.tags", + )?.value.arrayValue?.values; + return values?.length === 1 && values[0].stringValue === "app:v1-tags"; + }); + expect(tagged.length).toBeGreaterThan(0); + }); + test("can be disposed repeatedly", async () => { expect(hooks.dispose).toBeDefined(); await hooks.dispose?.(); diff --git a/test/integration/v2.test.ts b/test/integration/v2.test.ts index 964765f..071cc80 100644 --- a/test/integration/v2.test.ts +++ b/test/integration/v2.test.ts @@ -38,7 +38,16 @@ const SpanSchema = Schema.Struct({ attributes: Schema.Array( Schema.Struct({ key: Schema.String, - value: Schema.Struct({ stringValue: Schema.optional(Schema.String) }), + value: Schema.Struct({ + stringValue: Schema.optional(Schema.String), + arrayValue: Schema.optional( + Schema.Struct({ + values: Schema.Array( + Schema.Struct({ stringValue: Schema.optional(Schema.String) }), + ), + }), + ), + }), }), ), status: Schema.optional( @@ -75,6 +84,7 @@ function createPluginContext(input: { subscribe: PluginContext["event"]["subscribe"]; storage?: PluginContext["storage"]; getSession?: PluginContext["session"]["get"]; + options?: PluginContext["options"]; }) { const stored = new Map< string, @@ -107,12 +117,10 @@ function createPluginContext(input: { reload: unexpectedApi, }, event: { subscribe: input.subscribe }, + options: input.options ?? {}, get location() { return unexpectedApi(); }, - get options() { - return unexpectedApi(); - }, get agent() { return unexpectedApi(); }, @@ -1079,6 +1087,100 @@ test("omits V2 cost_details when OpenCode reports zero cost", async () => { expect( generations[0].attributes.map((attribute) => attribute.key), ).not.toContain("langfuse.observation.cost_details"); + expect( + spans.some((span) => + span.attributes.some( + (attribute) => attribute.key === "langfuse.trace.tags", + ), + ), + ).toBe(false); +}); + +test("applies plugin option tags to exported spans", async () => { + const { spans, collectorErrors } = await createCollector(); + const sessionID = Schema.decodeUnknownSync(SessionIDSchema)("v2-tags"); + const assistantMessageID = + Schema.decodeUnknownSync(MessageIDSchema)("v2-tags-assistant"); + const tokens = { + input: 10, + output: 5, + reasoning: 0, + cache: { read: 0, write: 0 }, + }; + const eventsFinished = Promise.withResolvers(); + const registration = { dispose: () => Promise.resolve() }; + const sessionHooks = createHookRegistry(); + const context = createPluginContext({ + sessionHook: sessionHooks.register, + toolHook: () => Promise.resolve(registration), + options: { tags: ["app:tags-test"] }, + subscribe: () => ({ + async *[Symbol.asyncIterator]() { + try { + await sessionHooks.run("prompt", { + sessionID, + messageID: + Schema.decodeUnknownSync(MessageIDSchema)("v2-tags-user"), + prompt: { text: "Tag this trace" }, + delivery: "steer", + }); + yield { + id: "v2-tags-started", + durable: { aggregateID: sessionID, seq: 1, version: 1 as const }, + type: "session.step.started", + created: Date.now(), + data: { + sessionID, + assistantMessageID, + agent: Agent.ID.make("build"), + model: { + id: Model.ID.make("test-model"), + providerID: Provider.ID.make("test-provider"), + }, + }, + }; + yield { + id: "v2-tags-ended", + durable: { aggregateID: sessionID, seq: 2, version: 1 as const }, + type: "session.step.ended", + created: Date.now(), + data: { + sessionID, + assistantMessageID, + finish: "stop", + cost: 0, + tokens, + }, + }; + yield { + id: "v2-tags-succeeded", + durable: { aggregateID: sessionID, seq: 3, version: 1 as const }, + type: "session.execution.succeeded", + created: Date.now(), + data: { sessionID }, + }; + } finally { + eventsFinished.resolve(undefined); + } + }, + }), + }); + + const cleanup = await LangfusePlugin.setup(context); + if (!cleanup) { + throw new Error("Expected plugin cleanup"); + } + onTestFinished(cleanup); + await eventsFinished.promise; + + expect(collectorErrors).toEqual([]); + const tagged = spans.filter((span) => { + const values = span.attributes.find( + (attribute) => attribute.key === "langfuse.trace.tags", + )?.value.arrayValue?.values; + return values?.length === 1 && values[0].stringValue === "app:tags-test"; + }); + expect(tagged.length).toBeGreaterThan(0); }); test("links V2 child sessions prompted before their creation event to the parent trace", async () => { @@ -1271,11 +1373,16 @@ const runtime = vi.hoisted(() => ({ forceFlush: vi.fn(), })); -vi.mock("../../src/runtime.js", async () => { +vi.mock("../../src/runtime.js", async (importOriginal) => { const { Effect } = await import("effect"); + const actual = await importOriginal(); return { - createLangfuseRuntime: (input: { opencodeVersion?: string }) => { + ...actual, + createLangfuseRuntime: (input: { + opencodeVersion?: string; + tags?: string[]; + }) => { runtime.createLangfuseRuntime(input); return Effect.succeed({ ...runtime, @@ -1315,6 +1422,7 @@ describe("OpenCode 2 package entrypoint", () => { const registration = { dispose: vi.fn(() => Promise.resolve()) }; const contextInput: unknown = { app: { version: "2.0.4" }, + options: {}, session: { hook: vi.fn(() => Promise.resolve(registration)) }, tool: { hook: vi.fn(() => Promise.resolve(registration)) }, event: { @@ -1384,6 +1492,7 @@ describe("OpenCode 2 package entrypoint", () => { const registration = { dispose: vi.fn(() => Promise.resolve()) }; const contextInput: unknown = { app: { version: "2.0.4" }, + options: {}, session: { hook: vi.fn(() => Promise.resolve(registration)) }, tool: { hook: vi.fn(() => Promise.resolve(registration)) }, event: { @@ -1436,6 +1545,7 @@ describe("OpenCode 2 package entrypoint", () => { const registration = { dispose: vi.fn(() => Promise.resolve()) }; const contextInput: unknown = { app: { version: "2.0.4" }, + options: {}, session: { hook: vi.fn((name: string, handler: typeof prompt) => { if (name === "prompt") { @@ -1585,6 +1695,7 @@ describe("OpenCode 2 package entrypoint", () => { const registration = { dispose: vi.fn(() => Promise.resolve()) }; const contextInput: unknown = { app: { version: "2.0.4" }, + options: {}, session: { hook: vi.fn(() => Promise.resolve(registration)) }, tool: { hook: vi.fn((name: string, handler: typeof executeBefore) => { @@ -1701,6 +1812,7 @@ describe("OpenCode 2 package entrypoint", () => { const registration = { dispose: vi.fn(() => Promise.resolve()) }; const contextInput: unknown = { app: { version: "2.0.4" }, + options: {}, session: { hook: vi.fn((name: string, handler: typeof context) => { if (name === "context") {