effect-cf 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-cf",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Effect-native primitives for Cloudflare Workers and bindings.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,8 +41,10 @@
41
41
  "devDependencies": {
42
42
  "@cloudflare/vitest-pool-workers": "^0.15.0",
43
43
  "@cloudflare/workers-types": "^4.20260421.1",
44
+ "@effect/platform-node": "4.0.0-beta.70",
44
45
  "@effect/sql-d1": "catalog:",
45
46
  "@effect/sql-pg": "catalog:",
47
+ "@effect/sql-sqlite-do": "catalog:",
46
48
  "@effect/vitest": "catalog:",
47
49
  "effect": "catalog:",
48
50
  "typescript": "^6.0.2",
@@ -52,6 +54,7 @@
52
54
  "peerDependencies": {
53
55
  "@effect/sql-d1": "^4.0.0-beta.70",
54
56
  "@effect/sql-pg": "^4.0.0-beta.70",
57
+ "@effect/sql-sqlite-do": "^4.0.0-beta.70",
55
58
  "effect": "^4.0.0-beta.70"
56
59
  },
57
60
  "peerDependenciesMeta": {
@@ -0,0 +1,628 @@
1
+ import { Config, ConfigProvider, Context, Effect, Layer, Option, Schema } from "effect";
2
+ import type * as Duration from "effect/Duration";
3
+ import type * as Tracer from "effect/Tracer";
4
+ import { FetchHttpClient, type Headers } from "effect/unstable/http";
5
+ import {
6
+ Otlp,
7
+ OtlpLogger,
8
+ OtlpMetrics,
9
+ OtlpSerialization,
10
+ OtlpTracer,
11
+ } from "effect/unstable/observability";
12
+
13
+ import * as DurableObject from "./DurableObject";
14
+ import { DurableObjectState } from "./DurableObjectState";
15
+ import { WorkerConfig, WorkerEnvironment } from "./Environment";
16
+ import * as Worker from "./Worker";
17
+
18
+ /** Telemetry signal groups supported by the Cloudflare OTLP layers. */
19
+ export type Signal = "logs" | "traces" | "metrics";
20
+
21
+ /** OTLP payload serialization used by the Effect OTLP exporters. */
22
+ export type Serialization = "json" | "protobuf";
23
+
24
+ /**
25
+ * Resolved OpenTelemetry settings used by the Cloudflare OTLP layers.
26
+ *
27
+ * The default {@link settingsLayer} reads the standard OpenTelemetry
28
+ * environment variable names from `WorkerEnvironment`. Applications that use
29
+ * different binding names can provide {@link CloudflareOtlpSettings} directly
30
+ * with a custom layer.
31
+ */
32
+ export interface Settings {
33
+ /** Generic OTLP base endpoint, read from `OTEL_EXPORTER_OTLP_ENDPOINT`. */
34
+ readonly endpoint: Option.Option<string>;
35
+ /** Logs-specific OTLP endpoint, read from `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT`. */
36
+ readonly logsEndpoint: Option.Option<string>;
37
+ /** Traces-specific OTLP endpoint, read from `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`. */
38
+ readonly tracesEndpoint: Option.Option<string>;
39
+ /** Metrics-specific OTLP endpoint, read from `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`. */
40
+ readonly metricsEndpoint: Option.Option<string>;
41
+ /** Comma-separated OTLP headers, read from `OTEL_EXPORTER_OTLP_HEADERS`. */
42
+ readonly headers: Option.Option<string>;
43
+ /** Logs-specific OTLP headers, read from `OTEL_EXPORTER_OTLP_LOGS_HEADERS`. */
44
+ readonly logsHeaders: Option.Option<string>;
45
+ /** Traces-specific OTLP headers, read from `OTEL_EXPORTER_OTLP_TRACES_HEADERS`. */
46
+ readonly tracesHeaders: Option.Option<string>;
47
+ /** Metrics-specific OTLP headers, read from `OTEL_EXPORTER_OTLP_METRICS_HEADERS`. */
48
+ readonly metricsHeaders: Option.Option<string>;
49
+ /** Service name, read from `OTEL_SERVICE_NAME`. */
50
+ readonly serviceName: Option.Option<string>;
51
+ /** Service version, read from the effect-cf convenience alias `OTEL_SERVICE_VERSION`. */
52
+ readonly serviceVersion: Option.Option<string>;
53
+ /** Resource attributes, read from `OTEL_RESOURCE_ATTRIBUTES`. */
54
+ readonly resourceAttributes: Option.Option<Record<string, string>>;
55
+ }
56
+
57
+ /**
58
+ * Supported OpenTelemetry environment configuration for Cloudflare OTLP export.
59
+ *
60
+ * This config intentionally uses the conventional `OTEL_*` variable names so
61
+ * deployments can share the same settings used by other OpenTelemetry SDKs.
62
+ * It covers the OTLP HTTP settings supported by the Effect OTLP layers:
63
+ *
64
+ * - `OTEL_EXPORTER_OTLP_ENDPOINT`
65
+ * - `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT`
66
+ * - `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`
67
+ * - `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`
68
+ * - `OTEL_EXPORTER_OTLP_HEADERS`
69
+ * - `OTEL_EXPORTER_OTLP_LOGS_HEADERS`
70
+ * - `OTEL_EXPORTER_OTLP_TRACES_HEADERS`
71
+ * - `OTEL_EXPORTER_OTLP_METRICS_HEADERS`
72
+ * - `OTEL_SERVICE_NAME`
73
+ * - `OTEL_RESOURCE_ATTRIBUTES`
74
+ *
75
+ * `OTEL_SERVICE_VERSION` is also accepted as an effect-cf convenience alias.
76
+ * The standard OpenTelemetry way to configure service version is
77
+ * `OTEL_RESOURCE_ATTRIBUTES=service.version=...`.
78
+ *
79
+ * Consumers with non-standard binding names should map those names into
80
+ * {@link CloudflareOtlpSettings} rather than changing this config globally.
81
+ */
82
+ export const settingsConfig = Config.all({
83
+ endpoint: Config.string("OTEL_EXPORTER_OTLP_ENDPOINT").pipe(Config.option),
84
+ logsEndpoint: Config.string("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT").pipe(Config.option),
85
+ tracesEndpoint: Config.string("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT").pipe(Config.option),
86
+ metricsEndpoint: Config.string("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT").pipe(Config.option),
87
+ headers: Config.string("OTEL_EXPORTER_OTLP_HEADERS").pipe(Config.option),
88
+ logsHeaders: Config.string("OTEL_EXPORTER_OTLP_LOGS_HEADERS").pipe(Config.option),
89
+ tracesHeaders: Config.string("OTEL_EXPORTER_OTLP_TRACES_HEADERS").pipe(Config.option),
90
+ metricsHeaders: Config.string("OTEL_EXPORTER_OTLP_METRICS_HEADERS").pipe(Config.option),
91
+ serviceName: Config.string("OTEL_SERVICE_NAME").pipe(Config.option),
92
+ serviceVersion: Config.string("OTEL_SERVICE_VERSION").pipe(Config.option),
93
+ resourceAttributes: Config.schema(
94
+ Config.Record(Schema.String, Schema.String),
95
+ "OTEL_RESOURCE_ATTRIBUTES",
96
+ ).pipe(Config.option),
97
+ });
98
+
99
+ /**
100
+ * Service carrying already-resolved Cloudflare OTLP settings.
101
+ *
102
+ * Provide this service directly when an application needs to map custom
103
+ * Cloudflare binding names, compute settings dynamically, or override settings
104
+ * in tests. When this service is present, the OTLP layers use it instead of
105
+ * reading {@link settingsConfig}.
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * const CustomOtlpSettings = Layer.effect(
110
+ * CloudflareOtlp.CloudflareOtlpSettings,
111
+ * Config.all({
112
+ * ...CloudflareOtlp.settingsConfig,
113
+ * endpoint: Config.string("MY_OTLP_ENDPOINT").pipe(Config.option),
114
+ * }),
115
+ * ).pipe(Layer.provide(WorkerConfig.layer));
116
+ * ```
117
+ */
118
+ export class CloudflareOtlpSettings extends Context.Service<CloudflareOtlpSettings, Settings>()(
119
+ "effect-cf/CloudflareOtlpSettings",
120
+ ) {}
121
+
122
+ /** Resource metadata shared by Worker and Durable Object OTLP layers. */
123
+ export interface ResourceOptions {
124
+ /** Explicit service name. Overrides `OTEL_SERVICE_NAME` when provided. */
125
+ readonly serviceName?: string;
126
+ /** Explicit service version. Overrides `OTEL_SERVICE_VERSION` when provided. */
127
+ readonly serviceVersion?: string;
128
+ /** Additional resource attributes attached to all exported telemetry. */
129
+ readonly attributes?: Record<string, unknown>;
130
+ }
131
+
132
+ /** Resource metadata specific to Cloudflare Workers. */
133
+ export interface WorkerResourceOptions extends ResourceOptions {
134
+ /** Cloudflare Worker name to attach as `cloudflare.worker.name`. */
135
+ readonly workerName?: string;
136
+ }
137
+
138
+ /** Resource metadata specific to Cloudflare Durable Objects. */
139
+ export interface DurableObjectResourceOptions extends ResourceOptions {
140
+ /** Durable Object class name to attach as `cloudflare.durable_object.class`. */
141
+ readonly className?: string;
142
+ /**
143
+ * Include the Durable Object id as `cloudflare.durable_object.id`.
144
+ *
145
+ * This is disabled by default because object ids can be high-cardinality.
146
+ */
147
+ readonly includeObjectId?: boolean;
148
+ }
149
+
150
+ /** Options shared by all Cloudflare OTLP telemetry layers. */
151
+ export interface LayerOptions extends ResourceOptions {
152
+ /** Telemetry signals to export. Defaults to logs, traces, and metrics. */
153
+ readonly signals?: ReadonlyArray<Signal>;
154
+ /** OTLP payload serialization. Defaults to protobuf. */
155
+ readonly serialization?: Serialization;
156
+ /** Maximum batch size for log and trace exporters. */
157
+ readonly maxBatchSize?: number;
158
+ /** Maximum time to wait for exporter shutdown/flush when a scope closes. */
159
+ readonly shutdownTimeout?: Duration.Input;
160
+ /** Export interval for the logs exporter. */
161
+ readonly loggerExportInterval?: Duration.Input;
162
+ /** Exclude log records emitted for spans when exporting logs. */
163
+ readonly loggerExcludeLogSpans?: boolean;
164
+ /** Merge the OTLP logger with existing loggers instead of replacing them. */
165
+ readonly loggerMergeWithExisting?: boolean;
166
+ /** Export interval for the traces exporter. */
167
+ readonly tracerExportInterval?: Duration.Input;
168
+ /** Custom trace context lookup used by the Effect OTLP tracer. */
169
+ readonly tracerContext?: <X>(primitive: Tracer.EffectPrimitive<X>, span: Tracer.AnySpan) => X;
170
+ /** Export interval for the metrics exporter. */
171
+ readonly metricsExportInterval?: Duration.Input;
172
+ /** Aggregation temporality for exported metrics. */
173
+ readonly metricsTemporality?: OtlpMetrics.AggregationTemporality;
174
+ }
175
+
176
+ const allSignals: ReadonlyArray<Signal> = ["logs", "traces", "metrics"];
177
+
178
+ const optionToUndefined = <A>(option: Option.Option<A>): A | undefined =>
179
+ Option.isSome(option) ? option.value : undefined;
180
+
181
+ const nonEmptyStringOption = (option: Option.Option<string>): Option.Option<string> =>
182
+ Option.flatMap(option, (value) => {
183
+ const trimmed = value.trim();
184
+ return trimmed === "" ? Option.none() : Option.some(trimmed);
185
+ });
186
+
187
+ const nonEmptyRecordOption = (
188
+ option: Option.Option<Record<string, string>>,
189
+ ): Option.Option<Record<string, string>> =>
190
+ Option.filter(option, (value) => Object.keys(value).length > 0);
191
+
192
+ const normalizeSettings = (settings: Settings): Settings => ({
193
+ endpoint: nonEmptyStringOption(settings.endpoint),
194
+ logsEndpoint: nonEmptyStringOption(settings.logsEndpoint),
195
+ tracesEndpoint: nonEmptyStringOption(settings.tracesEndpoint),
196
+ metricsEndpoint: nonEmptyStringOption(settings.metricsEndpoint),
197
+ headers: nonEmptyStringOption(settings.headers),
198
+ logsHeaders: nonEmptyStringOption(settings.logsHeaders),
199
+ tracesHeaders: nonEmptyStringOption(settings.tracesHeaders),
200
+ metricsHeaders: nonEmptyStringOption(settings.metricsHeaders),
201
+ serviceName: nonEmptyStringOption(settings.serviceName),
202
+ serviceVersion: nonEmptyStringOption(settings.serviceVersion),
203
+ resourceAttributes: nonEmptyRecordOption(settings.resourceAttributes),
204
+ });
205
+
206
+ /**
207
+ * Layer that reads standard OpenTelemetry settings from the current Cloudflare
208
+ * `env` object.
209
+ *
210
+ * The layer depends on {@link WorkerEnvironment} and installs a
211
+ * Cloudflare-backed Effect `ConfigProvider`, so `settingsConfig` reads from
212
+ * Worker vars/secrets instead of Node process environment variables.
213
+ */
214
+ export const settingsLayer: Layer.Layer<
215
+ CloudflareOtlpSettings,
216
+ Config.ConfigError,
217
+ WorkerEnvironment
218
+ > = Layer.effect(CloudflareOtlpSettings, settingsConfig.pipe(Config.map(normalizeSettings))).pipe(
219
+ Layer.provide(WorkerConfig.layer),
220
+ );
221
+
222
+ const selectedSignals = (signals: ReadonlyArray<Signal> | undefined): ReadonlySet<Signal> =>
223
+ new Set(signals ?? allSignals);
224
+
225
+ const withDefinedAttributes = (attributes: Record<string, unknown>): Record<string, unknown> => {
226
+ const out: Record<string, unknown> = {};
227
+ for (const [key, value] of Object.entries(attributes)) {
228
+ if (value !== undefined) {
229
+ out[key] = value;
230
+ }
231
+ }
232
+ return out;
233
+ };
234
+
235
+ const parseHeaders = (input: Option.Option<string>): Record<string, string> | undefined => {
236
+ if (Option.isNone(input) || input.value.trim() === "") {
237
+ return undefined;
238
+ }
239
+
240
+ const headers: Record<string, string> = {};
241
+ for (const part of input.value.split(",")) {
242
+ const index = part.indexOf("=");
243
+ if (index <= 0) {
244
+ continue;
245
+ }
246
+
247
+ const key = part.slice(0, index).trim();
248
+ const value = part.slice(index + 1).trim();
249
+ if (key !== "") {
250
+ headers[decodeHeaderComponent(key)] = decodeHeaderComponent(value);
251
+ }
252
+ }
253
+
254
+ return headers;
255
+ };
256
+
257
+ const mergeHeaders = (
258
+ common: Option.Option<string>,
259
+ signalSpecific: Option.Option<string>,
260
+ ): Headers.Input | undefined => {
261
+ const commonHeaders = parseHeaders(common);
262
+ const signalHeaders = parseHeaders(signalSpecific);
263
+ if (commonHeaders === undefined && signalHeaders === undefined) {
264
+ return undefined;
265
+ }
266
+
267
+ return {
268
+ ...commonHeaders,
269
+ ...signalHeaders,
270
+ };
271
+ };
272
+
273
+ const decodeHeaderComponent = (value: string): string => {
274
+ try {
275
+ return decodeURIComponent(value);
276
+ } catch {
277
+ return value;
278
+ }
279
+ };
280
+
281
+ const appendOtlpPath = (baseUrl: string, path: "/v1/logs" | "/v1/traces" | "/v1/metrics") =>
282
+ `${baseUrl.replace(/\/+$/, "")}${path}`;
283
+
284
+ const makeResource = (
285
+ settings: Settings,
286
+ options: LayerOptions,
287
+ runtimeAttributes: Record<string, unknown>,
288
+ ) => ({
289
+ serviceName: options.serviceName ?? optionToUndefined(settings.serviceName),
290
+ serviceVersion: options.serviceVersion ?? optionToUndefined(settings.serviceVersion),
291
+ attributes: {
292
+ ...optionToUndefined(settings.resourceAttributes),
293
+ ...withDefinedAttributes(runtimeAttributes),
294
+ ...options.attributes,
295
+ },
296
+ });
297
+
298
+ const commonOtlpOptions = (
299
+ settings: Settings,
300
+ options: LayerOptions,
301
+ runtimeAttributes: Record<string, unknown>,
302
+ ) => ({
303
+ resource: makeResource(settings, options, runtimeAttributes),
304
+ maxBatchSize: options.maxBatchSize,
305
+ loggerExportInterval: options.loggerExportInterval,
306
+ loggerExcludeLogSpans: options.loggerExcludeLogSpans,
307
+ loggerMergeWithExisting: options.loggerMergeWithExisting,
308
+ tracerExportInterval: options.tracerExportInterval,
309
+ tracerContext: options.tracerContext,
310
+ metricsExportInterval: options.metricsExportInterval,
311
+ metricsTemporality: options.metricsTemporality,
312
+ shutdownTimeout: options.shutdownTimeout,
313
+ });
314
+
315
+ const serializationLayer = (serialization: Serialization | undefined) =>
316
+ serialization === "json" ? OtlpSerialization.layerJson : OtlpSerialization.layerProtobuf;
317
+
318
+ const resolveSettings: Effect.Effect<Settings, Config.ConfigError> = Effect.gen(function* () {
319
+ const settings = yield* Effect.serviceOption(CloudflareOtlpSettings);
320
+ if (Option.isSome(settings)) {
321
+ return normalizeSettings(settings.value);
322
+ }
323
+
324
+ const env = yield* Effect.serviceOption(WorkerEnvironment);
325
+ if (Option.isSome(env)) {
326
+ const settings = yield* settingsConfig.pipe(
327
+ Effect.provide(ConfigProvider.layer(Effect.succeed(WorkerConfig.providerFromEnv(env.value)))),
328
+ );
329
+ return normalizeSettings(settings);
330
+ }
331
+
332
+ return normalizeSettings(yield* settingsConfig);
333
+ });
334
+
335
+ const mergeSignalLayers = (
336
+ layers: ReadonlyArray<Layer.Layer<never, never, never>>,
337
+ ): Layer.Layer<never, never, never> => {
338
+ if (layers.length === 0) {
339
+ return Layer.empty;
340
+ }
341
+
342
+ let merged = layers[0]!;
343
+ for (let index = 1; index < layers.length; index++) {
344
+ merged = Layer.mergeAll(merged, layers[index]!);
345
+ }
346
+ return merged;
347
+ };
348
+
349
+ const emptyLayer: Layer.Layer<never, never, never> = Layer.empty;
350
+
351
+ const makeLayerFromSettings = (
352
+ options: LayerOptions = {},
353
+ runtimeAttributes: Record<string, unknown> = {},
354
+ ): Layer.Layer<never, never, never> =>
355
+ Layer.unwrap(
356
+ Effect.map(resolveSettings, (settings) => {
357
+ const signals = selectedSignals(options.signals);
358
+ const endpoint = optionToUndefined(settings.endpoint);
359
+ const logsEndpoint = optionToUndefined(settings.logsEndpoint);
360
+ const tracesEndpoint = optionToUndefined(settings.tracesEndpoint);
361
+ const metricsEndpoint = optionToUndefined(settings.metricsEndpoint);
362
+ const hasSpecificEndpoint =
363
+ logsEndpoint !== undefined || tracesEndpoint !== undefined || metricsEndpoint !== undefined;
364
+ const hasSpecificHeaders =
365
+ Option.isSome(settings.logsHeaders) ||
366
+ Option.isSome(settings.tracesHeaders) ||
367
+ Option.isSome(settings.metricsHeaders);
368
+ const common = commonOtlpOptions(settings, options, runtimeAttributes);
369
+
370
+ if (
371
+ endpoint !== undefined &&
372
+ !hasSpecificEndpoint &&
373
+ !hasSpecificHeaders &&
374
+ signals.size === 3
375
+ ) {
376
+ return Otlp.layer({
377
+ baseUrl: endpoint,
378
+ headers: parseHeaders(settings.headers),
379
+ ...common,
380
+ }).pipe(
381
+ Layer.provide(serializationLayer(options.serialization)),
382
+ Layer.provide(FetchHttpClient.layer),
383
+ );
384
+ }
385
+
386
+ const layers: Array<Layer.Layer<never, never, never>> = [];
387
+ if (signals.has("logs")) {
388
+ const url =
389
+ logsEndpoint ??
390
+ (endpoint === undefined ? undefined : appendOtlpPath(endpoint, "/v1/logs"));
391
+ if (url !== undefined) {
392
+ layers.push(
393
+ OtlpLogger.layer({
394
+ url,
395
+ resource: common.resource,
396
+ headers: mergeHeaders(settings.headers, settings.logsHeaders),
397
+ exportInterval: common.loggerExportInterval,
398
+ maxBatchSize: common.maxBatchSize,
399
+ shutdownTimeout: common.shutdownTimeout,
400
+ excludeLogSpans: common.loggerExcludeLogSpans,
401
+ mergeWithExisting: common.loggerMergeWithExisting,
402
+ }).pipe(
403
+ Layer.provide(serializationLayer(options.serialization)),
404
+ Layer.provide(FetchHttpClient.layer),
405
+ ),
406
+ );
407
+ }
408
+ }
409
+
410
+ if (signals.has("traces")) {
411
+ const url =
412
+ tracesEndpoint ??
413
+ (endpoint === undefined ? undefined : appendOtlpPath(endpoint, "/v1/traces"));
414
+ if (url !== undefined) {
415
+ layers.push(
416
+ OtlpTracer.layer({
417
+ url,
418
+ resource: common.resource,
419
+ headers: mergeHeaders(settings.headers, settings.tracesHeaders),
420
+ exportInterval: common.tracerExportInterval,
421
+ maxBatchSize: common.maxBatchSize,
422
+ context: common.tracerContext,
423
+ shutdownTimeout: common.shutdownTimeout,
424
+ }).pipe(
425
+ Layer.provide(serializationLayer(options.serialization)),
426
+ Layer.provide(FetchHttpClient.layer),
427
+ ),
428
+ );
429
+ }
430
+ }
431
+
432
+ if (signals.has("metrics")) {
433
+ const url =
434
+ metricsEndpoint ??
435
+ (endpoint === undefined ? undefined : appendOtlpPath(endpoint, "/v1/metrics"));
436
+ if (url !== undefined) {
437
+ layers.push(
438
+ OtlpMetrics.layer({
439
+ url,
440
+ resource: common.resource,
441
+ headers: mergeHeaders(settings.headers, settings.metricsHeaders),
442
+ exportInterval: common.metricsExportInterval,
443
+ shutdownTimeout: common.shutdownTimeout,
444
+ temporality: common.metricsTemporality,
445
+ }).pipe(
446
+ Layer.provide(serializationLayer(options.serialization)),
447
+ Layer.provide(FetchHttpClient.layer),
448
+ ),
449
+ );
450
+ }
451
+ }
452
+
453
+ return mergeSignalLayers(layers);
454
+ }).pipe(
455
+ Effect.catch((error: Config.ConfigError) =>
456
+ Effect.logWarning("CloudflareOtlp disabled because OTLP configuration is invalid", {
457
+ error: String(error),
458
+ }).pipe(Effect.as(emptyLayer)),
459
+ ),
460
+ ),
461
+ );
462
+
463
+ const makeLayer = (
464
+ options: LayerOptions = {},
465
+ runtimeAttributes: Record<string, unknown> = {},
466
+ ): Layer.Layer<never, never, never> => makeLayerFromSettings(options, runtimeAttributes);
467
+
468
+ /**
469
+ * Base OTLP telemetry layer for Cloudflare-compatible runtimes.
470
+ *
471
+ * The layer reads {@link CloudflareOtlpSettings} when provided. Otherwise it
472
+ * reads standard OpenTelemetry config from `WorkerEnvironment` when available,
473
+ * then falls back to the ambient Effect `ConfigProvider`.
474
+ *
475
+ * When only `OTEL_EXPORTER_OTLP_ENDPOINT` is configured, per-signal paths are
476
+ * derived by appending `/v1/logs`, `/v1/traces`, and `/v1/metrics`. Signal
477
+ * specific endpoints are used as-is, matching the OpenTelemetry OTLP exporter
478
+ * convention.
479
+ */
480
+ export const layer = (options: LayerOptions = {}) => makeLayer(options);
481
+
482
+ /** Base OTLP telemetry layer forced to JSON serialization. */
483
+ export const layerJson = (options: Omit<LayerOptions, "serialization"> = {}) =>
484
+ layer({ ...options, serialization: "json" });
485
+
486
+ /** Base OTLP telemetry layer forced to protobuf serialization. */
487
+ export const layerProtobuf = (options: Omit<LayerOptions, "serialization"> = {}) =>
488
+ layer({ ...options, serialization: "protobuf" });
489
+
490
+ /**
491
+ * OTLP layer with Cloudflare Worker resource attributes.
492
+ *
493
+ * Provide this layer at runtime scope for long-lived metrics aggregation, or at
494
+ * handler scope when traces/logs should flush as the Cloudflare event finishes.
495
+ */
496
+ export const workerLayer = (options: LayerOptions & WorkerResourceOptions = {}) =>
497
+ makeLayer(options, {
498
+ "cloudflare.resource_type": "worker",
499
+ "cloudflare.worker.name": options.workerName,
500
+ });
501
+
502
+ /**
503
+ * OTLP layer with Cloudflare Durable Object resource attributes.
504
+ *
505
+ * The layer reads {@link DurableObjectState} so it can optionally include the
506
+ * Durable Object id. Prefer leaving `includeObjectId` disabled unless the
507
+ * backend can tolerate high-cardinality resource attributes.
508
+ */
509
+ export const durableObjectLayer = (options: LayerOptions & DurableObjectResourceOptions = {}) =>
510
+ Layer.unwrap(
511
+ Effect.map(DurableObjectState, (state) =>
512
+ makeLayer(options, {
513
+ "cloudflare.resource_type": "durable_object",
514
+ "cloudflare.durable_object.class": options.className,
515
+ "cloudflare.durable_object.id": options.includeObjectId ? state.id.toString() : undefined,
516
+ }),
517
+ ),
518
+ );
519
+
520
+ const provideRpcLayer = <
521
+ Rpc extends Record<string, (...args: Array<any>) => Effect.Effect<any, any, any>>,
522
+ RLayer,
523
+ >(
524
+ rpc: Rpc | undefined,
525
+ layer: Layer.Layer<never, never, RLayer>,
526
+ ): Rpc | undefined => {
527
+ if (rpc === undefined) {
528
+ return undefined;
529
+ }
530
+
531
+ return new Proxy(rpc, {
532
+ get(target, property, receiver) {
533
+ const handler = Reflect.get(target, property, receiver);
534
+ if (typeof handler !== "function") {
535
+ return handler;
536
+ }
537
+
538
+ return (...args: Array<any>) =>
539
+ Effect.suspend(() => Reflect.apply(handler, receiver, args)).pipe(Effect.provide(layer));
540
+ },
541
+ });
542
+ };
543
+
544
+ /**
545
+ * Instrument Worker lifecycle handlers with event-scoped OTLP telemetry.
546
+ *
547
+ * Each defined handler is provided with {@link workerLayer}. Because Worker
548
+ * handlers run inside `Effect.scoped`, the exporter scope closes and flushes at
549
+ * the end of each Cloudflare event.
550
+ */
551
+ export const instrumentWorkerOptions =
552
+ (options?: LayerOptions & WorkerResourceOptions) =>
553
+ <ROut, Rpc extends Worker.WorkerRpc<ROut>>(
554
+ handlers: Worker.WorkerOptions<ROut, Rpc>,
555
+ ): Worker.WorkerOptions<ROut | WorkerEnvironment | CloudflareOtlpSettings, Rpc> => {
556
+ const telemetryLayer = workerLayer(options);
557
+ const queue = handlers.queue;
558
+
559
+ return {
560
+ ...handlers,
561
+ fetch:
562
+ handlers.fetch === undefined
563
+ ? undefined
564
+ : handlers.fetch.pipe(Effect.provide(telemetryLayer)),
565
+ queue:
566
+ queue === undefined
567
+ ? undefined
568
+ : (batch) => queue(batch).pipe(Effect.provide(telemetryLayer)),
569
+ rpc: provideRpcLayer(handlers.rpc, telemetryLayer),
570
+ };
571
+ };
572
+
573
+ /**
574
+ * Instrument Durable Object lifecycle handlers with event-scoped OTLP telemetry.
575
+ *
576
+ * Each defined handler is provided with {@link durableObjectLayer}. Because
577
+ * Durable Object handlers run inside `Effect.scoped`, the exporter scope closes
578
+ * and flushes at the end of each Cloudflare event.
579
+ */
580
+ export const instrumentDurableObjectOptions =
581
+ (options?: LayerOptions & DurableObjectResourceOptions) =>
582
+ <ROut, Rpc extends DurableObject.DurableObjectRpc<ROut>>(
583
+ handlers: DurableObject.DurableObjectOptions<ROut, Rpc>,
584
+ ): DurableObject.DurableObjectOptions<
585
+ ROut | WorkerEnvironment | DurableObjectState | CloudflareOtlpSettings,
586
+ Rpc
587
+ > => {
588
+ const telemetryLayer = durableObjectLayer(options);
589
+ const alarm = handlers.alarm;
590
+ const webSocketMessage = handlers.webSocketMessage;
591
+ const webSocketClose = handlers.webSocketClose;
592
+ const webSocketError = handlers.webSocketError;
593
+
594
+ return {
595
+ ...handlers,
596
+ initialize:
597
+ handlers.initialize === undefined
598
+ ? undefined
599
+ : handlers.initialize.pipe(Effect.provide(telemetryLayer)),
600
+ fetch:
601
+ handlers.fetch === undefined
602
+ ? undefined
603
+ : handlers.fetch.pipe(Effect.provide(telemetryLayer)),
604
+ alarms:
605
+ handlers.alarms === undefined
606
+ ? undefined
607
+ : handlers.alarms.pipe(Effect.provide(telemetryLayer)),
608
+ alarm:
609
+ alarm === undefined
610
+ ? undefined
611
+ : (alarmInfo) => alarm(alarmInfo).pipe(Effect.provide(telemetryLayer)),
612
+ webSocketMessage:
613
+ webSocketMessage === undefined
614
+ ? undefined
615
+ : (socket, message) =>
616
+ webSocketMessage(socket, message).pipe(Effect.provide(telemetryLayer)),
617
+ webSocketClose:
618
+ webSocketClose === undefined
619
+ ? undefined
620
+ : (socket, code, reason, wasClean) =>
621
+ webSocketClose(socket, code, reason, wasClean).pipe(Effect.provide(telemetryLayer)),
622
+ webSocketError:
623
+ webSocketError === undefined
624
+ ? undefined
625
+ : (socket, error) => webSocketError(socket, error).pipe(Effect.provide(telemetryLayer)),
626
+ rpc: provideRpcLayer(handlers.rpc, telemetryLayer),
627
+ };
628
+ };
@@ -0,0 +1,26 @@
1
+ import { SqliteClient } from "@effect/sql-sqlite-do";
2
+ import { Effect, Layer } from "effect";
3
+
4
+ import { DurableObjectState } from "./DurableObjectState";
5
+
6
+ /** Options forwarded to `@effect/sql-sqlite-do` when building a SQL client layer. */
7
+ export type SqliteLayerOptions = Omit<SqliteClient.SqliteClientConfig, "db">;
8
+
9
+ /**
10
+ * Provides `effect/unstable/sql` from the current Durable Object SQLite storage.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * const RoomLayer = Layer.mergeAll(
15
+ * DurableObjectSqlite.layer(),
16
+ * OtherRoomServices,
17
+ * );
18
+ * ```
19
+ */
20
+ export const layer = (options?: SqliteLayerOptions) =>
21
+ Layer.unwrap(
22
+ Effect.gen(function* () {
23
+ const state = yield* DurableObjectState;
24
+ return SqliteClient.layer({ ...options, db: state.raw.storage.sql });
25
+ }),
26
+ );
package/src/index.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  export * as Binding from "./Binding";
2
+ export * as CloudflareOtlp from "./CloudflareOtlp";
2
3
  export * as D1 from "./D1";
3
4
  export * as DurableObject from "./DurableObject";
4
5
  export * as DurableObjectAlarm from "./DurableObjectAlarm";
5
6
  export * as DurableObjectDefinition from "./DurableObjectDefinition";
6
7
  export * as DurableObjectNamespace from "./DurableObjectNamespace";
7
8
  export * as DurableObjectRpcWebSocket from "./DurableObjectRpcWebSocket";
9
+ export * as DurableObjectSqlite from "./DurableObjectSqlite";
8
10
  export * as DurableObjectState from "./DurableObjectState";
9
11
  export * as DurableObjectWebSocket from "./DurableObjectWebSocket";
10
12
  export * as DurableObjectStorage from "./DurableObjectStorage";