diff --git a/README.md b/README.md index e238db1..7a1f5a9 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,20 @@ Create `~/.config/opencode/opencode-langfuse.json` with your Langfuse credential 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`. +Optional `tags` attaches Langfuse trace tags to every trace exported by the plugin (as an array in the config file, or comma-separated via the `LANGFUSE_TAGS` environment variable). Values from both sources are merged and de-duplicated: + +```json +{ + "publicKey": "pk-lf-...", + "secretKey": "sk-lf-...", + "tags": ["opencode", "production"] +} +``` + +```bash +export LANGFUSE_TAGS="opencode,production" +``` + You can also set credentials with environment variables: ```bash @@ -51,6 +65,7 @@ export LANGFUSE_SECRET_KEY="sk-lf-..." export LANGFUSE_BASE_URL="https://cloud.langfuse.com" export LANGFUSE_ENVIRONMENT="development" export LANGFUSE_USER_ID="your-user-id" +export LANGFUSE_TAGS="opencode,production" ``` If both `LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY` are set, the plugin uses environment variables instead of reading the config file. Optional values can be supplied either way. diff --git a/src/index.ts b/src/index.ts index ab12aa5..a86f6eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,9 @@ const LangfuseCredentialsSchema = Schema.Struct({ environment: Schema.optional(Schema.NonEmptyString), userId: Schema.optional(Schema.NonEmptyString), serviceName: Schema.optional(Schema.NonEmptyString), + tags: Schema.optional( + Schema.Array(Schema.NonEmptyString).pipe(Schema.maxItems(50)), + ), }); type LangfuseCredentials = typeof LangfuseCredentialsSchema.Type; @@ -35,6 +38,21 @@ class MissingLangfuseCredentials extends Data.TaggedError( "MissingLangfuseCredentials", ) {} +// LANGFUSE_TAGS accepts comma-separated tags: "opencode,production". Whitespace +// around separators is trimmed; empty segments are dropped. +const parseTagsEnv = (value: string | undefined): string[] | undefined => { + if (value === undefined || value === "") { + return undefined; + } + + const tags = value + .split(",") + .map((tag) => tag.trim()) + .filter((tag) => tag !== ""); + + return tags.length > 0 ? tags.slice(0, 50) : undefined; +}; + const loadLangfuseCredentials = Effect.gen(function* () { const publicKey = process.env.LANGFUSE_PUBLIC_KEY; const secretKey = process.env.LANGFUSE_SECRET_KEY; @@ -53,6 +71,7 @@ const loadLangfuseCredentials = Effect.gen(function* () { environment: process.env.LANGFUSE_ENVIRONMENT, userId: process.env.LANGFUSE_USER_ID, serviceName: process.env.LANGFUSE_SERVICE_NAME, + tags: parseTagsEnv(process.env.LANGFUSE_TAGS), } satisfies LangfuseCredentials; } @@ -396,6 +415,11 @@ const main = Effect.gen(function* () { const serviceName = credentials.serviceName ?? process.env.LANGFUSE_SERVICE_NAME; + // Config-file tags win; LANGFUSE_TAGS is the fallback so env-only setups + // can set tags too. Merge with de-duplication, preserving order. + const envTags = parseTagsEnv(process.env.LANGFUSE_TAGS) ?? []; + const tags = [...new Set([...(credentials.tags ?? []), ...envTags])]; + return yield* createLangfuseClient({ publicKey: credentials.publicKey, secretKey: credentials.secretKey, @@ -403,6 +427,7 @@ const main = Effect.gen(function* () { environment, userId, serviceName, + tags, }); }).pipe( Effect.tap((client) => diff --git a/src/langfuse.ts b/src/langfuse.ts index 1a29667..fc52830 100644 --- a/src/langfuse.ts +++ b/src/langfuse.ts @@ -1434,6 +1434,18 @@ const makePluginVersionSpanProcessor = () => forceFlush: () => Promise.resolve(), }) satisfies SpanProcessor; +// Trace tags, surfaced as Langfuse trace tags via the `langfuse.trace.tags` +// OTTL attribute recognized by @langfuse/core's OTEL endpoint. +const makeTraceTagsSpanProcessor = (tags: readonly string[]) => + ({ + onStart: (span: Span) => { + span.setAttribute("langfuse.trace.tags", JSON.stringify([...tags])); + }, + onEnd: () => undefined, + shutdown: () => Promise.resolve(), + forceFlush: () => Promise.resolve(), + }) satisfies SpanProcessor; + // Langfuse's OTEL processor may auto-mark exported spans as app roots, this overrides that. const makeAppRootSpanProcessor = (tracerName: string) => ({ @@ -1459,6 +1471,7 @@ export const createLangfuseClient = (input: { environment: string; userId?: string; serviceName?: string; + tags?: readonly string[]; }) => Effect.gen(function* () { const tracerName = "opencode-langfuse-plugin"; @@ -1510,6 +1523,9 @@ export const createLangfuseClient = (input: { ...(input.userId != null ? [makeUserIdSpanProcessor(input.userId)] : []), + ...(input.tags != null && input.tags.length > 0 + ? [makeTraceTagsSpanProcessor(input.tags)] + : []), processor, makeAppRootSpanProcessor(traceState.tracerName), ],