Skip to content

Commit 03355c1

Browse files
schicklingclaudetim-smart
authored
Fix Tracer.currentOtelSpan to work with OTLP module (#5890)
Co-authored-by: Claude <[email protected]> Co-authored-by: Tim Smart <[email protected]>
1 parent a0a84d8 commit 03355c1

File tree

4 files changed

+155
-9
lines changed

4 files changed

+155
-9
lines changed

.changeset/current-span-context.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@effect/opentelemetry": patch
3+
---
4+
5+
Fix `Tracer.currentOtelSpan` to work with OTLP module
6+
7+
`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.
8+
9+
Closes #5889

packages/opentelemetry/src/Tracer.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ export const makeExternalSpan: (
3030
) => ExternalSpan = internal.makeExternalSpan
3131

3232
/**
33+
* Get the current OpenTelemetry span.
34+
*
35+
* Works with both the official OpenTelemetry API (via `Tracer.layer`, `NodeSdk.layer`, etc.)
36+
* and the lightweight OTLP module (`OtlpTracer.layer`).
37+
*
38+
* When using OTLP, the returned span is a wrapper that conforms to the
39+
* OpenTelemetry `Span` interface.
40+
*
3341
* @since 1.0.0
3442
* @category accessors
3543
*/

packages/opentelemetry/src/internal/tracer.ts

Lines changed: 101 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import * as OtelApi from "@opentelemetry/api"
22
import * as OtelSemConv from "@opentelemetry/semantic-conventions"
33
import * as Cause from "effect/Cause"
4+
import type * as Clock from "effect/Clock"
45
import * as Context from "effect/Context"
56
import * as Effect from "effect/Effect"
6-
import type { Exit } from "effect/Exit"
7-
import { dual } from "effect/Function"
7+
import * as Exit from "effect/Exit"
8+
import { constTrue, dual } from "effect/Function"
89
import * as Layer from "effect/Layer"
910
import * as Option from "effect/Option"
1011
import * as EffectTracer from "effect/Tracer"
@@ -109,7 +110,7 @@ export class OtelSpan implements EffectTracer.Span {
109110
})))
110111
}
111112

112-
end(endTime: bigint, exit: Exit<unknown, unknown>) {
113+
end(endTime: bigint, exit: Exit.Exit<unknown, unknown>) {
113114
const hrTime = nanosToHrTime(endTime)
114115
this.status = {
115116
_tag: "Ended",
@@ -237,15 +238,106 @@ export const makeExternalSpan = (options: {
237238
}
238239
}
239240

241+
const makeOtelSpan = (span: EffectTracer.Span, clock: Clock.Clock): OtelApi.Span => {
242+
const spanContext: OtelApi.SpanContext = {
243+
traceId: span.traceId,
244+
spanId: span.spanId,
245+
traceFlags: span.sampled ? OtelApi.TraceFlags.SAMPLED : OtelApi.TraceFlags.NONE,
246+
isRemote: false
247+
}
248+
249+
let exit = Exit.void
250+
251+
const self: OtelApi.Span = {
252+
spanContext: () => spanContext,
253+
setAttribute(key, value) {
254+
span.attribute(key, value)
255+
return self
256+
},
257+
setAttributes(attributes) {
258+
for (const [key, value] of Object.entries(attributes)) {
259+
span.attribute(key, value)
260+
}
261+
return self
262+
},
263+
addEvent(name) {
264+
let attributes: OtelApi.Attributes | undefined = undefined
265+
let startTime: OtelApi.TimeInput | undefined = undefined
266+
if (arguments.length === 3) {
267+
attributes = arguments[1]
268+
startTime = arguments[2]
269+
} else {
270+
startTime = arguments[1]
271+
}
272+
span.event(name, convertOtelTimeInput(startTime, clock), attributes)
273+
return self
274+
},
275+
addLink(link) {
276+
span.addLinks([{
277+
_tag: "SpanLink",
278+
span: makeExternalSpan(link.context),
279+
attributes: link.attributes ?? {}
280+
}])
281+
return self
282+
},
283+
addLinks(links) {
284+
span.addLinks(links.map((link) => ({
285+
_tag: "SpanLink",
286+
span: makeExternalSpan(link.context),
287+
attributes: link.attributes ?? {}
288+
})))
289+
return self
290+
},
291+
setStatus(status) {
292+
exit = OtelApi.SpanStatusCode.ERROR
293+
? Exit.die(status.message ?? "Unknown error")
294+
: Exit.void
295+
return self
296+
},
297+
updateName: () => self,
298+
end(endTime) {
299+
const time = convertOtelTimeInput(endTime, clock)
300+
span.end(time, exit)
301+
return self
302+
},
303+
isRecording: constTrue,
304+
recordException(exception, timeInput) {
305+
const time = convertOtelTimeInput(timeInput, clock)
306+
const cause = Cause.fail(exception)
307+
const error = Cause.prettyErrors(cause)[0]
308+
span.event(error.message, time, {
309+
"exception.type": error.name,
310+
"exception.message": error.message,
311+
"exception.stacktrace": error.stack ?? ""
312+
})
313+
}
314+
}
315+
return self
316+
}
317+
318+
const bigint1e6 = BigInt(1_000_000)
319+
const bigint1e9 = BigInt(1_000_000_000)
320+
321+
const convertOtelTimeInput = (input: OtelApi.TimeInput | undefined, clock: Clock.Clock): bigint => {
322+
if (input === undefined) {
323+
return clock.unsafeCurrentTimeNanos()
324+
} else if (typeof input === "number") {
325+
return BigInt(Math.round(input * 1_000_000))
326+
} else if (input instanceof Date) {
327+
return BigInt(input.getTime()) * bigint1e6
328+
}
329+
const [seconds, nanos] = input
330+
return BigInt(seconds) * bigint1e9 + BigInt(nanos)
331+
}
332+
240333
/** @internal */
241-
export const currentOtelSpan = Effect.flatMap(
242-
Effect.currentSpan,
243-
(span) => {
334+
export const currentOtelSpan: Effect.Effect<OtelApi.Span, Cause.NoSuchElementException> = Effect.clockWith((clock) =>
335+
Effect.map(Effect.currentSpan, (span): OtelApi.Span => {
244336
if (OtelSpanTypeId in span) {
245-
return Effect.succeed((span as OtelSpan).span)
337+
return (span as OtelSpan).span
246338
}
247-
return Effect.fail(new Cause.NoSuchElementException())
248-
}
339+
return makeOtelSpan(span, clock)
340+
})
249341
)
250342

251343
/** @internal */

packages/opentelemetry/test/Tracer.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import * as NodeSdk from "@effect/opentelemetry/NodeSdk"
2+
import * as OtlpTracer from "@effect/opentelemetry/OtlpTracer"
23
import * as Tracer from "@effect/opentelemetry/Tracer"
4+
import { HttpClient } from "@effect/platform"
35
import { assert, describe, expect, it } from "@effect/vitest"
46
import * as OtelApi from "@opentelemetry/api"
57
import { AsyncHooksContextManager } from "@opentelemetry/context-async-hooks"
68
import { InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"
79
import * as Effect from "effect/Effect"
10+
import * as Layer from "effect/Layer"
811
import * as Runtime from "effect/Runtime"
912
import { OtelSpan } from "../src/internal/tracer.js"
1013

@@ -123,4 +126,38 @@ describe("Tracer", () => {
123126
})
124127
))
125128
})
129+
130+
describe("OTLP tracer", () => {
131+
const MockHttpClient = Layer.succeed(
132+
HttpClient.HttpClient,
133+
HttpClient.make(() => Effect.die("mock http client"))
134+
)
135+
const OtlpTracingLive = OtlpTracer.layer({
136+
url: "http://localhost:4318/v1/traces",
137+
resource: {
138+
serviceName: "test-otlp"
139+
}
140+
}).pipe(Layer.provide(MockHttpClient))
141+
142+
it.effect("currentOtelSpan works with OTLP tracer", () =>
143+
Effect.provide(
144+
Effect.withSpan("ok")(
145+
Effect.gen(function*() {
146+
const span = yield* Effect.currentSpan
147+
const otelSpan = yield* Tracer.currentOtelSpan
148+
const spanContext = otelSpan.spanContext()
149+
expect(spanContext.traceId).toBe(span.traceId)
150+
expect(spanContext.spanId).toBe(span.spanId)
151+
expect(spanContext.traceFlags).toBe(OtelApi.TraceFlags.SAMPLED)
152+
expect(spanContext.isRemote).toBe(false)
153+
expect(otelSpan.isRecording()).toBe(true)
154+
155+
// it should proxy attribute changes
156+
otelSpan.setAttribute("key", "value")
157+
expect(span.attributes.get("key")).toEqual("value")
158+
})
159+
),
160+
OtlpTracingLive
161+
))
162+
})
126163
})

0 commit comments

Comments
 (0)