Skip to main content

Telemetry

Effect-TS and OpenTelemetry Integration

How Wind and Cocoon use Effect-TS with @effect/opentelemetry for dual-sink span export.

Effect-TS and OpenTelemetry Integration

Wind and Cocoon are written in Effect-TS. Effect ships first-class OpenTelemetry support through @effect/opentelemetry. The Land integration lets Effect own span creation and attribute propagation, then export to two sinks: a BatchSpanProcessor to OTLP collector, and a custom SpanProcessor that re-emits each span as a PostHog event.

Package Surface

pnpm add @effect/opentelemetry \
         @opentelemetry/api \
         @opentelemetry/sdk-trace-base \
         @opentelemetry/sdk-trace-node \
         @opentelemetry/sdk-trace-web \
         @opentelemetry/exporter-trace-otlp-http \
         @opentelemetry/resources \
         @opentelemetry/semantic-conventions

sdk-trace-node for Cocoon (Node), sdk-trace-web for Sky (browser), sdk-trace-base for Wind (composes either).

Wiring

Cocoon (Node, Effect-TS)

// Element/Cocoon/Source/Telemetry/OTel.ts
import { NodeSdk } from "@effect/opentelemetry";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { Resource } from "@opentelemetry/resources";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { Layer } from "effect";

export default Layer.unwrapEffect(
	NodeSdk.layer(() => ({
		resource: {
			serviceName: "land-editor-cocoon",
			attributes: { "land.tier": "cocoon" },
		},
		spanProcessor: new BatchSpanProcessor(
			new OTLPTraceExporter({
				url: `${process.env["OTLPEndpoint"] ?? "http://127.0.0.1:4318"}/v1/traces`,
			}),
			{ maxExportBatchSize: 64, scheduledDelayMillis: 1500 },
		),
	})),
);

Compose into the application layer:

// Element/Cocoon/Source/Effect/AppLayer.ts
import { Layer } from "effect";

import OTelLayer from "../Telemetry/OTel.js";
import PostHogLayer from "../Telemetry/PostHogLayer.js";

const Telemetry =
	process.env["Capture"] === "false"
		? Layer.empty
		: Layer.mergeAll(OTelLayer, PostHogLayer);

export const AppLayer = Layer.mergeAll(Telemetry);

Capture=false collapses Telemetry to Layer.empty. Effect drops the service references at build time when paired with bundler define substitution.

Spans

Use Effect’s Effect.withSpan:

import { Effect } from "effect";

const HandleRequest = (Method: string, Parameters: unknown) =>
	Effect.gen(function* () {
		const Result = yield* doWork(Method, Parameters);
		return Result;
	}).pipe(
		Effect.withSpan("land:cocoon:handler", {
			attributes: { "land.method": Method },
		}),
	);

The span’s trace_id / span_id flow into PostHog through the custom processor. The span name follows the land:<element>:<action> convention.

Custom Processor — PostHog Dual-Emit

// Element/Cocoon/Source/Telemetry/PostHogProcessor.ts
import type {
	ReadableSpan,
	SpanProcessor,
} from "@opentelemetry/sdk-trace-base";

import { CaptureEvent } from "./PostHogBridge.js";

export default class PostHogSpanProcessor implements SpanProcessor {
	onStart(): void {}

	onEnd(Span: ReadableSpan): void {
		const Properties: Record<string, unknown> = {
			$trace_id: Span.spanContext().traceId,
			$span_id: Span.spanContext().spanId,
			$parent_span_id: Span.parentSpanContext?.spanId,
			duration_ms: Span.duration[0] * 1e3 + Span.duration[1] / 1e6,
			status_code: Span.status.code,
		};
		for (const [Key, Value] of Object.entries(Span.attributes)) {
			Properties[Key] = Value;
		}
		CaptureEvent(Span.name, Properties);
	}

	shutdown(): Promise<void> {
		return Promise.resolve();
	}
	forceFlush(): Promise<void> {
		return Promise.resolve();
	}
}

Register it alongside the OTLP BatchSpanProcessor so every span fans out into both sinks.

Sky (Browser)

// Element/Sky/Source/Function/Telemetry/Bridge.ts
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import {
	BatchSpanProcessor,
	WebTracerProvider,
} from "@opentelemetry/sdk-trace-web";

if (import.meta.env.DEV && import.meta.env["Capture"] !== "false") {
	const Provider = new WebTracerProvider({
		resource: {
			serviceName: "land-editor-sky",
			attributes: { "land.tier": "sky" },
		},
	});
	Provider.addSpanProcessor(
		new BatchSpanProcessor(
			new OTLPTraceExporter({
				url: `${import.meta.env["OTLPEndpoint"] ?? "http://127.0.0.1:4318"}/v1/traces`,
			}),
		),
	);
	Provider.register();
}

Tree-shakeable: import.meta.env.DEV is replaced at compile time by Vite/Astro with the literal false for production builds.

Mountain (Rust) Bridge

Mountain emits spans through the tracing crate. The bridge is tracing-opentelemetry, converting every tracing::span! into an OTLP span. otel_span! macro spans flow into Jaeger; the PostHog bridge in Binary/Build/PostHogPlugin/CaptureHandler receives the same (name, duration, attributes) triplet.

Cross-Tier Trace Propagation

Tauri events between Mountain and Sky carry the traceparent header (W3C format) on every IPC envelope. Wind extracts it from event.payload._traceparent, opens a child span with Effect.linkSpans, and forwards downstream. This lets Jaeger render a single trace that begins in Sky, hops Mountain (Rust), fans into Cocoon (Node), and rejoins on the response.

Mountain attaches the header in IPC/Sky/EmitToWebview.rs; Cocoon extracts it in Effect/RPCServer.ts.

When Not to Use Effect Spans

For single-callsite numeric events (land:cocoon:stub:active, land:wind:command:invoke) the PostHog bridge alone is sufficient. Reserve spans for operations with causal children: handler -> downstream gRPC, extension activation -> workspaceContains glob walk, build phase -> sub-build.


See Also