diff --git a/package.json b/package.json index 21b06fc..d284a13 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ }, "devDependencies": { "@langfuse/otel": "^5.4.1", + "@opentelemetry/resources": "^2.7.1", "@opentelemetry/sdk-trace-base": "^2.7.1", "@opentelemetry/sdk-trace-node": "^2.7.1", "@types/node": "^26.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2a379f..9b2cb10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@langfuse/otel': specifier: ^5.4.1 version: 5.4.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.218.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1)) + '@opentelemetry/resources': + specifier: ^2.7.1 + version: 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': specifier: ^2.7.1 version: 2.7.1(@opentelemetry/api@1.9.1) diff --git a/src/index.ts b/src/index.ts index a104475..f2e57c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -101,6 +101,7 @@ const LangfuseCredentialsSchema = Schema.Struct({ baseUrl: Schema.optional(Schema.NonEmptyString), environment: Schema.optional(Schema.NonEmptyString), userId: Schema.optional(Schema.NonEmptyString), + serviceName: Schema.optional(Schema.NonEmptyString), }); type LangfuseCredentials = typeof LangfuseCredentialsSchema.Type; @@ -121,6 +122,7 @@ const loadLangfuseCredentials = Effect.gen(function* () { baseUrl: process.env.LANGFUSE_BASE_URL ?? process.env.LANGFUSE_BASEURL, environment: process.env.LANGFUSE_ENVIRONMENT, userId: process.env.LANGFUSE_USER_ID, + serviceName: process.env.LANGFUSE_SERVICE_NAME, } satisfies LangfuseCredentials; } @@ -380,12 +382,16 @@ const main = Effect.gen(function* () { const userId = credentials.userId ?? process.env.LANGFUSE_USER_ID; + const serviceName = + credentials.serviceName ?? process.env.LANGFUSE_SERVICE_NAME; + return yield* createLangfuseClient({ publicKey: credentials.publicKey, secretKey: credentials.secretKey, baseUrl, environment, userId, + serviceName, }); }).pipe( Effect.tap((client) => diff --git a/src/langfuse.ts b/src/langfuse.ts index c843acd..7cd9fec 100644 --- a/src/langfuse.ts +++ b/src/langfuse.ts @@ -7,6 +7,12 @@ import type { Span, SpanProcessor, } from "@opentelemetry/sdk-trace-base"; +import { + defaultResource, + detectResources, + envDetector, + resourceFromAttributes, +} from "@opentelemetry/resources"; import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; import { Context as EffectContext, Effect } from "effect"; @@ -1333,6 +1339,7 @@ export const createLangfuseClient = (input: { baseUrl: string; environment: string; userId?: string; + serviceName?: string; }) => Effect.gen(function* () { const tracerName = "opencode-langfuse-plugin"; @@ -1368,6 +1375,16 @@ export const createLangfuseClient = (input: { }); const provider = new NodeTracerProvider({ + // Honor OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES, which a bare + // NodeTracerProvider does not read on its own. Merged-in attributes win, + // so a configured serviceName overrides the environment. + resource: defaultResource() + .merge(detectResources({ detectors: [envDetector] })) + .merge( + input.serviceName + ? resourceFromAttributes({ "service.name": input.serviceName }) + : null, + ), spanProcessors: [ makePluginVersionSpanProcessor(), ...(input.userId ? [makeUserIdSpanProcessor(input.userId)] : []), diff --git a/test/integration/plugin.test.ts b/test/integration/plugin.test.ts index 3203b04..8a4769b 100644 --- a/test/integration/plugin.test.ts +++ b/test/integration/plugin.test.ts @@ -37,6 +37,7 @@ interface CapturedRequest { headers: IncomingHttpHeaders; body: { resourceSpans: Array<{ + resource?: { attributes?: Array<{ key: string; value: OtlpValue }> }; scopeSpans: Array<{ spans: OtlpSpan[] }>; }>; }; @@ -171,6 +172,17 @@ const getAttributes = (span: OtlpSpan) => ]), ); +const getResourceAttributes = (request: CapturedRequest) => + Object.fromEntries( + request.body.resourceSpans.flatMap( + (resourceSpan) => + resourceSpan.resource?.attributes?.map(({ key, value }) => [ + key, + value.stringValue ?? value.boolValue ?? value.intValue, + ]) ?? [], + ), + ); + const getJsonAttribute = (span: OtlpSpan, key: string) => { const value = getAttributes(span)[key]; if (typeof value !== "string") { @@ -1621,6 +1633,75 @@ describe.sequential("built plugin", () => { } }, 10_000); + test("resolves service.name from config, OTEL environment variables, then default", async () => { + const originalValues = { + OTEL_SERVICE_NAME: process.env.OTEL_SERVICE_NAME, + OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, + LANGFUSE_SERVICE_NAME: process.env.LANGFUSE_SERVICE_NAME, + }; + + const recreateHooks = async () => { + await disposeHooks(); + hooksDisposed = false; + hooks = await createHooks(collectorBaseUrl); + }; + + const getSessionResourceAttributes = async (sessionID: string) => { + await sendUserMessage({ + sessionID, + messageID: `${sessionID}-user`, + text: "Trace for resource attributes", + started: startedAt, + }); + const { requests: sessionRequests } = await flushSession(sessionID); + expect(sessionRequests.length).toBeGreaterThan(0); + return sessionRequests.map(getResourceAttributes); + }; + + try { + delete process.env.OTEL_SERVICE_NAME; + delete process.env.OTEL_RESOURCE_ATTRIBUTES; + delete process.env.LANGFUSE_SERVICE_NAME; + await recreateHooks(); + + for (const attributes of await getSessionResourceAttributes( + "resource-default-session", + )) { + expect(attributes["service.name"]).toMatch(/^unknown_service/); + expect(attributes["deployment.environment"]).toBeUndefined(); + } + + process.env.OTEL_SERVICE_NAME = "env-service"; + process.env.OTEL_RESOURCE_ATTRIBUTES = "deployment.environment=ci"; + await recreateHooks(); + + for (const attributes of await getSessionResourceAttributes( + "resource-env-session", + )) { + expect(attributes["service.name"]).toBe("env-service"); + expect(attributes["deployment.environment"]).toBe("ci"); + } + + process.env.LANGFUSE_SERVICE_NAME = "config-service"; + await recreateHooks(); + + for (const attributes of await getSessionResourceAttributes( + "resource-config-session", + )) { + expect(attributes["service.name"]).toBe("config-service"); + expect(attributes["deployment.environment"]).toBe("ci"); + } + } finally { + for (const [name, value] of Object.entries(originalValues)) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + } + }, 15_000); + test("can be disposed repeatedly", async () => { expect(hooks.dispose).toBeDefined(); await hooks.dispose?.();