Skip to content

Commit f367067

Browse files
Nick Ficanoclaude
andcommitted
effect(middleware-otel): add OtelTracerLayer bridge alongside legacy withTracing
Add @effect/opentelemetry integration via OtelTracerLayer({ tracerProvider, resource }) — a thin bridge that lifts a consumer-supplied OTel TracerProvider into an Effect Layer providing OtelTracer / OtelTracerProvider / Resource. Effect-shape consumers compose it into ARCPRuntimeLayer so Effect.withSpan calls inside the runtime emit through the same provider that withTracing (Transport-level §11 propagation) already uses. The published 0.1.0 withTracing API is unchanged. NodeSdk.layer intentionally not exposed: choosing SpanProcessor / exporter / propagator belongs to the consumer's observability stack, and the issue forbids a new dependency on @opentelemetry/sdk-node. First test infra in this package (vitest.config.ts + test/). Three tests: layer composability smoke, span-export with parent/child shape through BasicTracerProvider + InMemorySpanExporter, and a withTracing re-export regression guard. Closes #49 Closes #28 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bebb26d commit f367067

6 files changed

Lines changed: 286 additions & 1 deletion

File tree

packages/middleware/otel/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,17 @@
4444
"peerDependencies": {
4545
"@arcp/core": "workspace:^",
4646
"@effect/opentelemetry": "^0.63.0",
47-
"@opentelemetry/api": "^1.9.0"
47+
"@opentelemetry/api": "^1.9.0",
48+
"effect": "^3.21.2"
4849
},
4950
"devDependencies": {
5051
"@arcp/core": "workspace:*",
5152
"@effect/opentelemetry": "^0.63.0",
5253
"@opentelemetry/api": "^1.9.0",
54+
"@opentelemetry/resources": "^2.0.0",
55+
"@opentelemetry/sdk-trace-base": "^2.0.0",
5356
"@types/node": "^22.7.5",
57+
"effect": "^3.21.2",
5458
"typescript": "^5.6.2",
5559
"vitest": "^2.1.2"
5660
}

packages/middleware/otel/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ import {
4141
import type { WithTracingOptions } from "./types.js";
4242

4343
export type { WithTracingOptions } from "./types.js";
44+
export {
45+
OtelTracerLayer,
46+
type OtelTracerLayerOptions,
47+
} from "./otel-effect.js";
4448

4549
// Per ARCP §15 (IANA / extension namespace), all envelope extensions must be
4650
// in the `x-vendor.<vendor>.<name>` namespace. The OTel propagation carrier
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* Effect-shape integration for `@arcp/middleware-otel`.
3+
*
4+
* Slice #49 of the Effect migration. This module sits alongside the legacy
5+
* Transport-level {@link withTracing} wrapper (preserved unchanged in
6+
* `./index.ts`) and exposes a small, compositional bridge from
7+
* `@effect/opentelemetry` into an ARCP runtime built with
8+
* {@link makeARCPServerRuntime} / {@link ARCPRuntimeLayer}.
9+
*
10+
* Scope of this module
11+
* --------------------
12+
* Two distinct concerns are at play:
13+
*
14+
* 1. **Wire-level propagation.** Stamping the W3C `traceparent` carrier
15+
* into `envelope.extensions["x-vendor.opentelemetry.tracecontext"]` so
16+
* that ARCP §11 spans link across hops. That contract is owned by
17+
* {@link withTracing} (Transport wrapper) and is unchanged.
18+
*
19+
* 2. **Effect-workflow tracing.** Letting spans opened by `Effect.withSpan`
20+
* and `Effect.useSpan` inside an ARCP runtime appear in the same OTel
21+
* trace as the spans emitted by (1). That is what this module adds.
22+
*
23+
* Why we don't expose a `NodeSdk.layer` here
24+
* ------------------------------------------
25+
* `@effect/opentelemetry`'s `NodeSdk.layer` constructs a full Node tracer
26+
* provider from `@opentelemetry/sdk-trace-node`, `@opentelemetry/resources`,
27+
* and a user-supplied `SpanProcessor`. That decision (which exporter, which
28+
* batching strategy, which propagator, etc.) belongs to the consumer's
29+
* observability stack, not to a middleware package. Forcing a particular
30+
* SDK shape would also force a hard dependency on `@opentelemetry/sdk-node`
31+
* — which the issue explicitly forbids ("No new dependency on
32+
* `@opentelemetry/sdk-node`; consumer brings their own SDK; we only bridge").
33+
*
34+
* Instead we expose {@link OtelTracerLayer}, a thin bridge that takes an
35+
* already-constructed OTel `TracerProvider` (whatever the consumer wired up
36+
* — `NodeTracerProvider`, `WebTracerProvider`, `@vercel/otel`, a no-op
37+
* provider for tests, or `trace.getTracerProvider()` after global registration)
38+
* and lifts it into an Effect {@link Layer} that satisfies the
39+
* `OtelTracer.OtelTracer` and `OtelTracer.OtelTracerProvider` requirements
40+
* for any downstream effect that calls `Effect.withSpan(...)`.
41+
*
42+
* Usage
43+
* -----
44+
* ```ts
45+
* import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
46+
* import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
47+
* import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
48+
* import { Layer } from "effect";
49+
* import { ARCPRuntimeLayer } from "@arcp/runtime";
50+
* import { OtelTracerLayer } from "@arcp/middleware-otel";
51+
*
52+
* const provider = new NodeTracerProvider();
53+
* provider.addSpanProcessor(new BatchSpanProcessor(new OTLPTraceExporter()));
54+
* provider.register(); // also makes withTracing happy for §11 propagation
55+
*
56+
* const MainLayer = Layer.provideMerge(
57+
* ARCPRuntimeLayer(serverOptions),
58+
* OtelTracerLayer({
59+
* tracerProvider: provider,
60+
* resource: { serviceName: "arcp-runtime" },
61+
* }),
62+
* );
63+
* ```
64+
*
65+
* The legacy {@link withTracing} wrapper continues to work side-by-side: it
66+
* pulls the active OTel context from `@opentelemetry/api` (which the global
67+
* provider above satisfies), so a single registered provider feeds both the
68+
* Transport-level `arcp.send` / `arcp.recv` spans and the Effect-workflow
69+
* spans opened inside the runtime.
70+
*/
71+
import * as OtelResource from "@effect/opentelemetry/Resource";
72+
import * as OtelTracer from "@effect/opentelemetry/Tracer";
73+
import type * as OtelApi from "@opentelemetry/api";
74+
import { Layer } from "effect";
75+
76+
/**
77+
* Configuration for {@link OtelTracerLayer}.
78+
*
79+
* Both fields are required: the bridge needs a real OTel `TracerProvider`
80+
* to delegate span creation to, and a `resource` (service name / version /
81+
* attributes) so that `provider.getTracer(serviceName, serviceVersion)`
82+
* resolves to a stable tracer per the OTel spec.
83+
*/
84+
export interface OtelTracerLayerOptions {
85+
/**
86+
* The OpenTelemetry `TracerProvider` to bridge into Effect. Typically the
87+
* same provider passed to `provider.register()` so that the legacy
88+
* {@link withTracing} Transport wrapper (which reads from the global API)
89+
* and Effect-workflow spans share a single export pipeline.
90+
*/
91+
readonly tracerProvider: OtelApi.TracerProvider;
92+
93+
/**
94+
* Resource attributes used to name the bridged tracer. `serviceName` is
95+
* required; `serviceVersion` and `attributes` are optional and forwarded
96+
* to `@effect/opentelemetry`'s `Resource.layer` unchanged.
97+
*/
98+
readonly resource: {
99+
readonly serviceName: string;
100+
readonly serviceVersion?: string;
101+
readonly attributes?: OtelApi.Attributes;
102+
};
103+
}
104+
105+
/**
106+
* Build an Effect {@link Layer} that bridges Effect's tracer to a
107+
* consumer-supplied OTel `TracerProvider`.
108+
*
109+
* The resulting layer eliminates the `OtelTracer.OtelTracer`,
110+
* `OtelTracer.OtelTracerProvider`, and `OtelResource.Resource`
111+
* requirements for downstream effects, and replaces the default
112+
* Effect tracer with one that emits OTel spans through the supplied
113+
* provider. Compose it via `Layer.provideMerge` into an
114+
* `ARCPRuntimeLayer`-built program; no other plumbing is required.
115+
*
116+
* This is purely an Effect-shape addition. The Transport-level
117+
* `withTracing` wrapper exported from `./index.ts` is unchanged and
118+
* continues to be the right tool for §11 wire propagation.
119+
*/
120+
export const OtelTracerLayer = (
121+
options: OtelTracerLayerOptions,
122+
): Layer.Layer<never> => {
123+
const providerLayer = Layer.succeed(
124+
OtelTracer.OtelTracerProvider,
125+
options.tracerProvider,
126+
);
127+
const resourceLayer = OtelResource.layer(options.resource);
128+
const tracerLayer = OtelTracer.layer;
129+
return Layer.provide(
130+
tracerLayer,
131+
Layer.merge(providerLayer, resourceLayer),
132+
);
133+
};
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Behavior + smoke coverage for `otel-effect.ts`. These tests stay scoped
2+
// to the middleware package by driving `OtelTracerLayer` against an
3+
// in-memory `BasicTracerProvider` + `InMemorySpanExporter` from
4+
// `@opentelemetry/sdk-trace-base`. We assert:
5+
//
6+
// 1) `OtelTracerLayer` is a valid Effect Layer with no leftover
7+
// requirements (smoke test — proves the bridge can be composed into
8+
// `ARCPRuntimeLayer` without further plumbing).
9+
// 2) Spans opened via `Effect.withSpan` inside the resulting program are
10+
// exported through the consumer-supplied `TracerProvider`, with the
11+
// configured `serviceName` recorded on the tracer and the expected
12+
// parent/child structure preserved (the §11 acceptance scenario,
13+
// reduced to a single Effect-shape workflow).
14+
// 3) The legacy `withTracing` re-export still works (regression guard
15+
// for the published 0.1.0 surface — no behavior change in slice #49).
16+
17+
import {
18+
InMemorySpanExporter,
19+
SimpleSpanProcessor,
20+
BasicTracerProvider,
21+
} from "@opentelemetry/sdk-trace-base";
22+
import { Effect } from "effect";
23+
import { describe, expect, it } from "vitest";
24+
25+
import { OtelTracerLayer, withTracing } from "../src/index.js";
26+
27+
describe("OtelTracerLayer", () => {
28+
it("composes into a runnable program with no leftover requirements", async () => {
29+
const exporter = new InMemorySpanExporter();
30+
const provider = new BasicTracerProvider({
31+
spanProcessors: [new SimpleSpanProcessor(exporter)],
32+
});
33+
34+
const layer = OtelTracerLayer({
35+
tracerProvider: provider,
36+
resource: { serviceName: "arcp-runtime-test" },
37+
});
38+
39+
// If the layer leaves any requirements the type-system would already
40+
// have failed; this provideLayer call is the runtime smoke-test.
41+
const program = Effect.succeed("ok").pipe(
42+
Effect.withSpan("smoke"),
43+
Effect.provide(layer),
44+
);
45+
46+
const result = await Effect.runPromise(program);
47+
expect(result).toBe("ok");
48+
49+
await provider.shutdown();
50+
});
51+
52+
it("exports Effect spans through the supplied OTel TracerProvider with the configured serviceName", async () => {
53+
const exporter = new InMemorySpanExporter();
54+
const provider = new BasicTracerProvider({
55+
spanProcessors: [new SimpleSpanProcessor(exporter)],
56+
});
57+
58+
const layer = OtelTracerLayer({
59+
tracerProvider: provider,
60+
resource: {
61+
serviceName: "arcp-runtime",
62+
serviceVersion: "0.1.0",
63+
},
64+
});
65+
66+
// session.handshake → job.submit → handler (per #49 acceptance shape,
67+
// reduced to a single Effect workflow — wire-level propagation across
68+
// session boundaries is covered by `withTracing` and is out of scope
69+
// for this Effect-shape bridge).
70+
const program = Effect.gen(function* () {
71+
yield* Effect.succeed(undefined).pipe(
72+
Effect.withSpan("job.submit"),
73+
);
74+
yield* Effect.succeed(undefined).pipe(Effect.withSpan("handler"));
75+
}).pipe(Effect.withSpan("session.handshake"), Effect.provide(layer));
76+
77+
await Effect.runPromise(program);
78+
79+
// SimpleSpanProcessor flushes per-span synchronously, but force a
80+
// flush to be defensive against batching changes upstream.
81+
await provider.forceFlush();
82+
const spans = exporter.getFinishedSpans();
83+
84+
const names = spans.map((s) => s.name).sort();
85+
expect(names).toEqual(["handler", "job.submit", "session.handshake"]);
86+
87+
// Tracer name = serviceName (per @effect/opentelemetry's layerTracer);
88+
// assert the bridge actually delegated to provider.getTracer(...).
89+
for (const s of spans) {
90+
expect(s.instrumentationScope.name).toBe("arcp-runtime");
91+
expect(s.instrumentationScope.version).toBe("0.1.0");
92+
}
93+
94+
// Verify the parent/child structure: session.handshake is the root,
95+
// and both job.submit and handler hang off it.
96+
const byName = new Map(spans.map((s) => [s.name, s]));
97+
const root = byName.get("session.handshake");
98+
const submit = byName.get("job.submit");
99+
const handler = byName.get("handler");
100+
expect(root?.parentSpanContext).toBeUndefined();
101+
expect(submit?.parentSpanContext?.spanId).toBe(
102+
root?.spanContext().spanId,
103+
);
104+
expect(handler?.parentSpanContext?.spanId).toBe(
105+
root?.spanContext().spanId,
106+
);
107+
108+
await provider.shutdown();
109+
});
110+
111+
it("re-exports the legacy withTracing wrapper unchanged", () => {
112+
// Regression guard: slice #49 must not alter the published 0.1.0
113+
// surface. We only assert the export is a callable function — its
114+
// behavior is exercised by consumers (no tests previously existed
115+
// in this package).
116+
expect(typeof withTracing).toBe("function");
117+
});
118+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { defineConfig } from "vitest/config";
2+
3+
export default defineConfig({
4+
test: {
5+
include: ["test/**/*.test.ts"],
6+
environment: "node",
7+
pool: "threads",
8+
testTimeout: 10_000,
9+
hookTimeout: 10_000,
10+
coverage: {
11+
provider: "v8",
12+
reporter: ["text", "html", "lcov"],
13+
include: ["src/**/*.ts"],
14+
exclude: ["src/**/index.ts", "src/**/*.d.ts"],
15+
},
16+
},
17+
});

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)