Skip to content
Merged
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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) =>
Expand Down
17 changes: 17 additions & 0 deletions src/langfuse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -1333,6 +1339,7 @@ export const createLangfuseClient = (input: {
baseUrl: string;
environment: string;
userId?: string;
serviceName?: string;
}) =>
Effect.gen(function* () {
const tracerName = "opencode-langfuse-plugin";
Expand Down Expand Up @@ -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)] : []),
Expand Down
81 changes: 81 additions & 0 deletions test/integration/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ interface CapturedRequest {
headers: IncomingHttpHeaders;
body: {
resourceSpans: Array<{
resource?: { attributes?: Array<{ key: string; value: OtlpValue }> };
scopeSpans: Array<{ spans: OtlpSpan[] }>;
}>;
};
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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?.();
Expand Down