diff --git a/.changeset/dry-cycles-shake.md b/.changeset/dry-cycles-shake.md new file mode 100644 index 00000000000..76b43bcee6a --- /dev/null +++ b/.changeset/dry-cycles-shake.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Add type-level utils to asserting layer types diff --git a/.changeset/dynamic-idle-ttl-rcmap.md b/.changeset/dynamic-idle-ttl-rcmap.md new file mode 100644 index 00000000000..b4deb3b59cc --- /dev/null +++ b/.changeset/dynamic-idle-ttl-rcmap.md @@ -0,0 +1,17 @@ +--- +"effect": minor +--- + +RcMap: support dynamic `idleTimeToLive` values per key + +The `idleTimeToLive` option can now be a function that receives the key and returns a duration, allowing different TTL values for different resources. + +```ts +const map = yield* RcMap.make({ + lookup: (key: string) => acquireResource(key), + idleTimeToLive: (key: string) => { + if (key.startsWith("premium:")) return Duration.minutes(10) + return Duration.minutes(1) + } +}) +``` diff --git a/.changeset/fast-shoes-appear.md b/.changeset/fast-shoes-appear.md new file mode 100644 index 00000000000..80a4adda4f8 --- /dev/null +++ b/.changeset/fast-shoes-appear.md @@ -0,0 +1,17 @@ +--- +"@effect/opentelemetry": patch +"effect": patch +--- + +Add logs to first propagated span, in the following case before this fix the log would not be added to the `p` span because `Effect.fn` adds a fake span for the purpose of adding a stack frame. + +```ts +import { Effect } from "effect" + +const f = Effect.fn(function* () { + yield* Effect.logWarning("FooBar") + return yield* Effect.fail("Oops") +}) + +const p = f().pipe(Effect.withSpan("p")) +``` diff --git a/.changeset/violet-years-stare.md b/.changeset/violet-years-stare.md new file mode 100644 index 00000000000..1e320c0ca62 --- /dev/null +++ b/.changeset/violet-years-stare.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Fix annotateCurrentSpan, add Effect.currentPropagatedSpan diff --git a/packages/effect/dtslint/Layer.tst.ts b/packages/effect/dtslint/Layer.tst.ts index 40168a49908..909927bcae5 100644 --- a/packages/effect/dtslint/Layer.tst.ts +++ b/packages/effect/dtslint/Layer.tst.ts @@ -1,4 +1,4 @@ -import { Layer, Schedule } from "effect" +import { Context, Layer, Schedule } from "effect" import { describe, expect, it } from "tstyche" interface In1 {} @@ -19,6 +19,8 @@ interface Out3 {} declare const layer3: Layer.Layer +class TestService1 extends Context.Tag("TestService1")() {} + describe("Layer", () => { it("merge", () => { expect(Layer.merge).type.not.toBeCallableWith() @@ -40,4 +42,28 @@ describe("Layer", () => { expect(Layer.retry(layer1, Schedule.recurs(1))).type.toBe>() expect(layer1.pipe(Layer.retry(Schedule.recurs(1)))).type.toBe>() }) + + it("ensureSuccessType", () => { + expect(layer1.pipe(Layer.ensureSuccessType())).type.toBe>() + }) + + it("ensureErrorType", () => { + const withoutError = Layer.succeed(TestService1, {}) + expect(withoutError.pipe(Layer.ensureErrorType())).type.toBe>() + + const withError = layer1 + expect(withError.pipe(Layer.ensureErrorType())).type.toBe>() + }) + + it("ensureRequirementsType", () => { + const withoutRequirements = Layer.succeed(TestService1, {}) + expect(withoutRequirements.pipe(Layer.ensureRequirementsType())).type.toBe< + Layer.Layer + >() + + const withRequirement = layer1 + expect(withRequirement.pipe(Layer.ensureRequirementsType())).type.toBe< + Layer.Layer + >() + }) }) diff --git a/packages/effect/src/Effect.ts b/packages/effect/src/Effect.ts index b1e897d82b1..d9143c239b6 100644 --- a/packages/effect/src/Effect.ts +++ b/packages/effect/src/Effect.ts @@ -12998,6 +12998,12 @@ export const annotateCurrentSpan: { */ export const currentSpan: Effect = effect.currentSpan +/** + * @since 3.20.0 + * @category Tracing + */ +export const currentPropagatedSpan: Effect = effect.currentPropagatedSpan + /** * @since 2.0.0 * @category Tracing diff --git a/packages/effect/src/Layer.ts b/packages/effect/src/Layer.ts index 82aa92f6fa6..35627c5a235 100644 --- a/packages/effect/src/Layer.ts +++ b/packages/effect/src/Layer.ts @@ -1226,3 +1226,55 @@ export const updateService = dual< layer, map(context(), (c) => Context.add(c, tag, f(Context.unsafeGet(c, tag)))) )) + +// ----------------------------------------------------------------------------- +// Type constraints +// ----------------------------------------------------------------------------- + +/** + * A no-op type constraint that enforces the success channel of a Layer conforms to + * the specified success type `ROut`. + * + * @example + * import { Layer } from "effect" + * + * // Ensure that the layer produces the expected services. + * const program = Layer.succeed(MyService, new MyServiceImpl()).pipe(Layer.ensureSuccessType()) + * + * @since 3.20.0 + * @category Type constraints + */ +export const ensureSuccessType = + () => (layer: Layer): Layer => layer + +/** + * A no-op type constraint that enforces the error channel of a Layer conforms to + * the specified error type `E`. + * + * @example + * import { Layer } from "effect" + * + * // Ensure that the layer does not expose any unhandled errors. + * const program = Layer.succeed(MyService, new MyServiceImpl()).pipe(Layer.ensureErrorType()) + * + * @since 3.20.0 + * @category Type constraints + */ +export const ensureErrorType = () => (layer: Layer): Layer => + layer + +/** + * A no-op type constraint that enforces the requirements channel of a Layer conforms to + * the specified requirements type `RIn`. + * + * @example + * import { Layer } from "effect" + * + * // Ensure that the layer does not have any requirements. + * const program = Layer.succeed(MyService, new MyServiceImpl()).pipe(Layer.ensureRequirementsType()) + * + * @since 3.20.0 + * @category Type constraints + */ +export const ensureRequirementsType = + () => (layer: Layer): Layer => layer diff --git a/packages/effect/src/RcMap.ts b/packages/effect/src/RcMap.ts index efd93797083..9e78cdce1f9 100644 --- a/packages/effect/src/RcMap.ts +++ b/packages/effect/src/RcMap.ts @@ -56,6 +56,7 @@ export declare namespace RcMap { * * - `capacity`: The maximum number of resources that can be held in the map. * - `idleTimeToLive`: When the reference count reaches zero, the resource will be released after this duration. + * Can be a static duration or a function that returns a duration based on the key. * * @since 3.5.0 * @category models @@ -85,14 +86,14 @@ export const make: { ( options: { readonly lookup: (key: K) => Effect.Effect - readonly idleTimeToLive?: Duration.DurationInput | undefined + readonly idleTimeToLive?: Duration.DurationInput | ((key: K) => Duration.DurationInput) | undefined readonly capacity?: undefined } ): Effect.Effect, never, Scope.Scope | R> ( options: { readonly lookup: (key: K) => Effect.Effect - readonly idleTimeToLive?: Duration.DurationInput | undefined + readonly idleTimeToLive?: Duration.DurationInput | ((key: K) => Duration.DurationInput) | undefined readonly capacity: number } ): Effect.Effect, never, Scope.Scope | R> diff --git a/packages/effect/src/internal/core-effect.ts b/packages/effect/src/internal/core-effect.ts index 2b90e1d0068..0ef68fb67b6 100644 --- a/packages/effect/src/internal/core-effect.ts +++ b/packages/effect/src/internal/core-effect.ts @@ -1966,7 +1966,7 @@ export const annotateCurrentSpan: { } = function(): Effect.Effect { const args = arguments return ignore(core.flatMap( - currentSpan, + currentPropagatedSpan, (span) => core.sync(() => { if (typeof args[0] === "string") { @@ -2041,6 +2041,16 @@ export const currentSpan: Effect.Effect = core.flatMap( + core.context(), + (context) => { + const span = filterDisablePropagation(Context.getOption(context, internalTracer.spanTag)) + return span._tag === "Some" && span.value._tag === "Span" + ? core.succeed(span.value) + : core.fail(new core.NoSuchElementException()) + } +) + /* @internal */ export const linkSpans = dual< ( @@ -2070,12 +2080,13 @@ export const linkSpans = dual< const bigint0 = BigInt(0) -const filterDisablePropagation: (self: Option.Option) => Option.Option = Option.flatMap( - (span) => - Context.get(span.context, internalTracer.DisablePropagation) - ? span._tag === "Span" ? filterDisablePropagation(span.parent) : Option.none() - : Option.some(span) -) +export const filterDisablePropagation: (self: Option.Option) => Option.Option = Option + .flatMap( + (span) => + Context.get(span.context, internalTracer.DisablePropagation) + ? span._tag === "Span" ? filterDisablePropagation(span.parent) : Option.none() + : Option.some(span) + ) /** @internal */ export const unsafeMakeSpan = ( diff --git a/packages/effect/src/internal/fiberRuntime.ts b/packages/effect/src/internal/fiberRuntime.ts index 8362eb2b34a..52ffccf95d2 100644 --- a/packages/effect/src/internal/fiberRuntime.ts +++ b/packages/effect/src/internal/fiberRuntime.ts @@ -1517,13 +1517,15 @@ export const tracerLogger = globalValue( logLevel, message }) => { - const span = Context.getOption( + const span = internalEffect.filterDisablePropagation(Context.getOption( fiberRefs.getOrDefault(context, core.currentContext), tracer.spanTag - ) + )) + if (span._tag === "None" || span.value._tag === "ExternalSpan") { return } + const clockService = Context.unsafeGet( fiberRefs.getOrDefault(context, defaultServices.currentServices), clock.clockTag diff --git a/packages/effect/src/internal/rcMap.ts b/packages/effect/src/internal/rcMap.ts index 12c84ef44f2..b5980220b3a 100644 --- a/packages/effect/src/internal/rcMap.ts +++ b/packages/effect/src/internal/rcMap.ts @@ -4,7 +4,7 @@ import type * as Deferred from "../Deferred.js" import * as Duration from "../Duration.js" import type { Effect } from "../Effect.js" import type { RuntimeFiber } from "../Fiber.js" -import { dual, identity } from "../Function.js" +import { constant, dual, flow, identity } from "../Function.js" import * as MutableHashMap from "../MutableHashMap.js" import { pipeArguments } from "../Pipeable.js" import type * as RcMap from "../RcMap.js" @@ -33,6 +33,7 @@ declare namespace State { readonly deferred: Deferred.Deferred readonly scope: Scope.CloseableScope readonly finalizer: Effect + readonly idleTimeToLive: Duration.Duration fiber: RuntimeFiber | undefined expiresAt: number refCount: number @@ -58,7 +59,7 @@ class RcMapImpl implements RcMap.RcMap { readonly lookup: (key: K) => Effect, readonly context: Context.Context, readonly scope: Scope.Scope, - readonly idleTimeToLive: Duration.Duration | undefined, + readonly idleTimeToLive: ((key: K) => Duration.Duration) | undefined, readonly capacity: number ) { this[TypeId] = variance @@ -73,27 +74,32 @@ class RcMapImpl implements RcMap.RcMap { export const make: { (options: { readonly lookup: (key: K) => Effect - readonly idleTimeToLive?: Duration.DurationInput | undefined + readonly idleTimeToLive?: Duration.DurationInput | ((key: K) => Duration.DurationInput) | undefined readonly capacity?: undefined }): Effect, never, Scope.Scope | R> (options: { readonly lookup: (key: K) => Effect - readonly idleTimeToLive?: Duration.DurationInput | undefined + readonly idleTimeToLive?: Duration.DurationInput | ((key: K) => Duration.DurationInput) | undefined readonly capacity: number }): Effect, never, Scope.Scope | R> } = (options: { readonly lookup: (key: K) => Effect - readonly idleTimeToLive?: Duration.DurationInput | undefined + readonly idleTimeToLive?: Duration.DurationInput | ((key: K) => Duration.DurationInput) | undefined readonly capacity?: number | undefined }) => core.withFiberRuntime, never, R | Scope.Scope>((fiber) => { const context = fiber.getFiberRef(core.currentContext) as Context.Context const scope = Context.get(context, fiberRuntime.scopeTag) + const idleTimeToLive = options.idleTimeToLive === undefined + ? undefined + : typeof options.idleTimeToLive === "function" + ? flow(options.idleTimeToLive, Duration.decode) + : constant(Duration.decode(options.idleTimeToLive)) const self = new RcMapImpl( options.lookup as any, context, scope, - options.idleTimeToLive ? Duration.decode(options.idleTimeToLive) : undefined, + idleTimeToLive, Math.max(options.capacity ?? Number.POSITIVE_INFINITY, 0) ) return core.as( @@ -169,10 +175,12 @@ const acquire = core.fnUntraced(function*(self: RcMapImpl, key core.flatMap((exit) => core.deferredDone(deferred, exit)), circular.forkIn(scope) ) + const idleTimeToLive = self.idleTimeToLive ? self.idleTimeToLive(key) : Duration.zero const entry: State.Entry = { deferred, scope, finalizer: undefined as any, + idleTimeToLive, fiber: undefined, expiresAt: 0, refCount: 1 @@ -192,7 +200,7 @@ const release = (self: RcMapImpl, key: K, entry: State.Entry(self: RcMapImpl, key: K, entry: State.Entry { @@ -276,10 +284,12 @@ export const touch: { (self_: RcMap.RcMap, key: K) => coreEffect.clockWith((clock) => { const self = self_ as RcMapImpl - if (!self.idleTimeToLive || self.state._tag === "Closed") return core.void + if (self.state._tag === "Closed") return core.void const o = MutableHashMap.get(self.state.map, key) if (o._tag === "None") return core.void - o.value.expiresAt = clock.unsafeCurrentTimeMillis() + Duration.toMillis(self.idleTimeToLive) + const entry = o.value + if (Duration.isZero(entry.idleTimeToLive)) return core.void + entry.expiresAt = clock.unsafeCurrentTimeMillis() + Duration.toMillis(entry.idleTimeToLive) return core.void }) ) diff --git a/packages/effect/test/RcMap.test.ts b/packages/effect/test/RcMap.test.ts index e661149a5c0..5d5ec2b1fb3 100644 --- a/packages/effect/test/RcMap.test.ts +++ b/packages/effect/test/RcMap.test.ts @@ -175,4 +175,63 @@ describe("RcMap", () => { deepStrictEqual(yield* RcMap.keys(map), ["foo", "bar", "baz"]) })) + + it.scoped("dynamic idleTimeToLive", () => + Effect.gen(function*() { + const acquired: Array = [] + const released: Array = [] + const map = yield* RcMap.make({ + lookup: (key: string) => + Effect.acquireRelease( + Effect.sync(() => { + acquired.push(key) + return key + }), + () => Effect.sync(() => released.push(key)) + ), + idleTimeToLive: (key: string) => key.startsWith("short:") ? 500 : 2000 + }) + + deepStrictEqual(acquired, []) + + yield* Effect.scoped(RcMap.get(map, "short:a")) + yield* Effect.scoped(RcMap.get(map, "long:b")) + deepStrictEqual(acquired, ["short:a", "long:b"]) + deepStrictEqual(released, []) + + yield* TestClock.adjust(500) + deepStrictEqual(released, ["short:a"]) + + yield* TestClock.adjust(1500) + deepStrictEqual(released, ["short:a", "long:b"]) + })) + + it.scoped("dynamic idleTimeToLive with touch", () => + Effect.gen(function*() { + const acquired: Array = [] + const released: Array = [] + const map = yield* RcMap.make({ + lookup: (key: string) => + Effect.acquireRelease( + Effect.sync(() => { + acquired.push(key) + return key + }), + () => Effect.sync(() => released.push(key)) + ), + idleTimeToLive: (key: string) => key.startsWith("short:") ? 500 : 2000 + }) + + yield* Effect.scoped(RcMap.get(map, "short:a")) + deepStrictEqual(acquired, ["short:a"]) + deepStrictEqual(released, []) + + yield* TestClock.adjust(250) + yield* RcMap.touch(map, "short:a") + yield* TestClock.adjust(250) + deepStrictEqual(released, []) + + yield* TestClock.adjust(250) + deepStrictEqual(released, ["short:a"]) + })) }) diff --git a/packages/opentelemetry/test/Tracer.test.ts b/packages/opentelemetry/test/Tracer.test.ts index 11fd074e1be..45eb0e2ef82 100644 --- a/packages/opentelemetry/test/Tracer.test.ts +++ b/packages/opentelemetry/test/Tracer.test.ts @@ -6,17 +6,27 @@ 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 Console from "effect/Console" import * as Effect from "effect/Effect" +import * as FiberRef from "effect/FiberRef" import * as Layer from "effect/Layer" import * as Runtime from "effect/Runtime" import { OtelSpan } from "../src/internal/tracer.js" -const TracingLive = NodeSdk.layer(Effect.sync(() => ({ - resource: { - serviceName: "test" - }, - spanProcessor: [new SimpleSpanProcessor(new InMemorySpanExporter())] -}))) +class Exporter extends Effect.Service()("Exporter", { + effect: Effect.sync(() => ({ exporter: new InMemorySpanExporter() })) +}) {} + +const TracingLive = Layer.unwrapEffect(Effect.gen(function*() { + const { exporter } = yield* Exporter + + return NodeSdk.layer(Effect.sync(() => ({ + resource: { + serviceName: "test" + }, + spanProcessor: [new SimpleSpanProcessor(exporter)] + }))) +})).pipe(Layer.provideMerge(Exporter.Default)) // needed to test context propagation const contextManager = new AsyncHooksContextManager() @@ -160,4 +170,59 @@ describe("Tracer", () => { OtlpTracingLive )) }) + + describe("Log Attributes", () => { + it.effect("propagates attributes with Effect.fnUntraced", () => + Effect.gen(function*() { + const f = Effect.fnUntraced(function*() { + yield* Effect.logWarning("FooBar") + return yield* Effect.fail("Oops") + }) + + const p = f().pipe(Effect.withSpan("p")) + + yield* Effect.ignore(p) + + const { exporter } = yield* Exporter + + assert.isNotEmpty(exporter.getFinishedSpans()[0].events.filter((_) => _.name === "FooBar")) + assert.isNotEmpty(exporter.getFinishedSpans()[0].events.filter((_) => _.name === "exception")) + }).pipe(Effect.provide(TracingLive))) + + it.effect("propagates attributes with Effect.fn(name)", () => + Effect.gen(function*() { + const f = Effect.fn("f")(function*() { + yield* Effect.logWarning("FooBar") + return yield* Effect.fail("Oops") + }) + + const p = f().pipe(Effect.withSpan("p")) + + yield* Effect.ignore(p) + + const { exporter } = yield* Exporter + + assert.isNotEmpty(exporter.getFinishedSpans()[0].events.filter((_) => _.name === "FooBar")) + assert.isNotEmpty(exporter.getFinishedSpans()[0].events.filter((_) => _.name === "exception")) + }).pipe(Effect.provide(TracingLive))) + + it.effect("propagates attributes with Effect.fn", () => + Effect.gen(function*() { + const f = Effect.fn(function*() { + yield* Effect.logWarning("FooBar") + return yield* Effect.fail("Oops") + }) + + const p = f().pipe(Effect.withSpan("p")) + + yield* Effect.ignore(p) + + const { exporter } = yield* Exporter + + yield* Console.log(Array.from(yield* FiberRef.get(FiberRef.currentLoggers))) + + assert.isNotEmpty(exporter.getFinishedSpans()[0].events.filter((_) => _.name === "FooBar")) + assert.isNotEmpty(exporter.getFinishedSpans()[0].events.filter((_) => _.name === "exception")) + }).pipe(Effect.provide(TracingLive))) + }) })