diff --git a/.changeset/current-span-context.md b/.changeset/current-span-context.md new file mode 100644 index 00000000000..88925e66bef --- /dev/null +++ b/.changeset/current-span-context.md @@ -0,0 +1,9 @@ +--- +"@effect/opentelemetry": patch +--- + +Fix `Tracer.currentOtelSpan` to work with OTLP module + +`currentOtelSpan` now works with both the official OpenTelemetry SDK and the lightweight OTLP module. When using OTLP, it returns a wrapper that conforms to the OpenTelemetry Span interface. + +Closes #5889 diff --git a/packages/opentelemetry/src/Tracer.ts b/packages/opentelemetry/src/Tracer.ts index 121dc7d9604..28140621de7 100644 --- a/packages/opentelemetry/src/Tracer.ts +++ b/packages/opentelemetry/src/Tracer.ts @@ -30,6 +30,14 @@ export const makeExternalSpan: ( ) => ExternalSpan = internal.makeExternalSpan /** + * Get the current OpenTelemetry span. + * + * Works with both the official OpenTelemetry API (via `Tracer.layer`, `NodeSdk.layer`, etc.) + * and the lightweight OTLP module (`OtlpTracer.layer`). + * + * When using OTLP, the returned span is a wrapper that conforms to the + * OpenTelemetry `Span` interface. + * * @since 1.0.0 * @category accessors */ diff --git a/packages/opentelemetry/src/internal/tracer.ts b/packages/opentelemetry/src/internal/tracer.ts index b67ea0303eb..8b857b8de36 100644 --- a/packages/opentelemetry/src/internal/tracer.ts +++ b/packages/opentelemetry/src/internal/tracer.ts @@ -1,10 +1,11 @@ import * as OtelApi from "@opentelemetry/api" import * as OtelSemConv from "@opentelemetry/semantic-conventions" import * as Cause from "effect/Cause" +import type * as Clock from "effect/Clock" import * as Context from "effect/Context" import * as Effect from "effect/Effect" -import type { Exit } from "effect/Exit" -import { dual } from "effect/Function" +import * as Exit from "effect/Exit" +import { constTrue, dual } from "effect/Function" import * as Layer from "effect/Layer" import * as Option from "effect/Option" import * as EffectTracer from "effect/Tracer" @@ -109,7 +110,7 @@ export class OtelSpan implements EffectTracer.Span { }))) } - end(endTime: bigint, exit: Exit) { + end(endTime: bigint, exit: Exit.Exit) { const hrTime = nanosToHrTime(endTime) this.status = { _tag: "Ended", @@ -237,15 +238,106 @@ export const makeExternalSpan = (options: { } } +const makeOtelSpan = (span: EffectTracer.Span, clock: Clock.Clock): OtelApi.Span => { + const spanContext: OtelApi.SpanContext = { + traceId: span.traceId, + spanId: span.spanId, + traceFlags: span.sampled ? OtelApi.TraceFlags.SAMPLED : OtelApi.TraceFlags.NONE, + isRemote: false + } + + let exit = Exit.void + + const self: OtelApi.Span = { + spanContext: () => spanContext, + setAttribute(key, value) { + span.attribute(key, value) + return self + }, + setAttributes(attributes) { + for (const [key, value] of Object.entries(attributes)) { + span.attribute(key, value) + } + return self + }, + addEvent(name) { + let attributes: OtelApi.Attributes | undefined = undefined + let startTime: OtelApi.TimeInput | undefined = undefined + if (arguments.length === 3) { + attributes = arguments[1] + startTime = arguments[2] + } else { + startTime = arguments[1] + } + span.event(name, convertOtelTimeInput(startTime, clock), attributes) + return self + }, + addLink(link) { + span.addLinks([{ + _tag: "SpanLink", + span: makeExternalSpan(link.context), + attributes: link.attributes ?? {} + }]) + return self + }, + addLinks(links) { + span.addLinks(links.map((link) => ({ + _tag: "SpanLink", + span: makeExternalSpan(link.context), + attributes: link.attributes ?? {} + }))) + return self + }, + setStatus(status) { + exit = OtelApi.SpanStatusCode.ERROR + ? Exit.die(status.message ?? "Unknown error") + : Exit.void + return self + }, + updateName: () => self, + end(endTime) { + const time = convertOtelTimeInput(endTime, clock) + span.end(time, exit) + return self + }, + isRecording: constTrue, + recordException(exception, timeInput) { + const time = convertOtelTimeInput(timeInput, clock) + const cause = Cause.fail(exception) + const error = Cause.prettyErrors(cause)[0] + span.event(error.message, time, { + "exception.type": error.name, + "exception.message": error.message, + "exception.stacktrace": error.stack ?? "" + }) + } + } + return self +} + +const bigint1e6 = BigInt(1_000_000) +const bigint1e9 = BigInt(1_000_000_000) + +const convertOtelTimeInput = (input: OtelApi.TimeInput | undefined, clock: Clock.Clock): bigint => { + if (input === undefined) { + return clock.unsafeCurrentTimeNanos() + } else if (typeof input === "number") { + return BigInt(Math.round(input * 1_000_000)) + } else if (input instanceof Date) { + return BigInt(input.getTime()) * bigint1e6 + } + const [seconds, nanos] = input + return BigInt(seconds) * bigint1e9 + BigInt(nanos) +} + /** @internal */ -export const currentOtelSpan = Effect.flatMap( - Effect.currentSpan, - (span) => { +export const currentOtelSpan: Effect.Effect = Effect.clockWith((clock) => + Effect.map(Effect.currentSpan, (span): OtelApi.Span => { if (OtelSpanTypeId in span) { - return Effect.succeed((span as OtelSpan).span) + return (span as OtelSpan).span } - return Effect.fail(new Cause.NoSuchElementException()) - } + return makeOtelSpan(span, clock) + }) ) /** @internal */ diff --git a/packages/opentelemetry/test/Tracer.test.ts b/packages/opentelemetry/test/Tracer.test.ts index 0a2f6fa422e..11fd074e1be 100644 --- a/packages/opentelemetry/test/Tracer.test.ts +++ b/packages/opentelemetry/test/Tracer.test.ts @@ -1,10 +1,13 @@ import * as NodeSdk from "@effect/opentelemetry/NodeSdk" +import * as OtlpTracer from "@effect/opentelemetry/OtlpTracer" import * as Tracer from "@effect/opentelemetry/Tracer" +import { HttpClient } from "@effect/platform" import { assert, describe, expect, it } from "@effect/vitest" import * as OtelApi from "@opentelemetry/api" import { AsyncHooksContextManager } from "@opentelemetry/context-async-hooks" import { InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base" import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" import * as Runtime from "effect/Runtime" import { OtelSpan } from "../src/internal/tracer.js" @@ -123,4 +126,38 @@ describe("Tracer", () => { }) )) }) + + describe("OTLP tracer", () => { + const MockHttpClient = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("mock http client")) + ) + const OtlpTracingLive = OtlpTracer.layer({ + url: "http://localhost:4318/v1/traces", + resource: { + serviceName: "test-otlp" + } + }).pipe(Layer.provide(MockHttpClient)) + + it.effect("currentOtelSpan works with OTLP tracer", () => + Effect.provide( + Effect.withSpan("ok")( + Effect.gen(function*() { + const span = yield* Effect.currentSpan + const otelSpan = yield* Tracer.currentOtelSpan + const spanContext = otelSpan.spanContext() + expect(spanContext.traceId).toBe(span.traceId) + expect(spanContext.spanId).toBe(span.spanId) + expect(spanContext.traceFlags).toBe(OtelApi.TraceFlags.SAMPLED) + expect(spanContext.isRemote).toBe(false) + expect(otelSpan.isRecording()).toBe(true) + + // it should proxy attribute changes + otelSpan.setAttribute("key", "value") + expect(span.attributes.get("key")).toEqual("value") + }) + ), + OtlpTracingLive + )) + }) })